From 0ea392b5591421b2f03e2454bc3b7e86dc537568 Mon Sep 17 00:00:00 2001 From: Elly Kitoto Date: Wed, 27 Sep 2023 08:23:34 +0300 Subject: [PATCH] Filter register data via configuration (#2780) * Filter register data via configuration Signed-off-by: Elly Kitoto * Add missing related resources Signed-off-by: Elly Kitoto * Implement data filters for related resources Signed-off-by: Elly Kitoto * Implement data filtered register count badge Signed-off-by: Elly Kitoto * Fix failing tests Signed-off-by: Elly Kitoto * Test load paging source data with FhirResourceConfig Signed-off-by: Elly Kitoto * Test that refreshData is invoked with QuestionnaireResponse param Signed-off-by: Elly Kitoto * Write test for RegisterViewModel#updateRegisterFilterState Signed-off-by: Elly Kitoto * Implement clear all UI Signed-off-by: Elly Kitoto * Apply no filter if no data field answer is provided Also fix rendering of badge count. Filtered data can return 0. Reset default value to -1. Signed-off-by: Elly Kitoto --------- Signed-off-by: Elly Kitoto --- .../configuration/QuestionnaireConfig.kt | 1 + .../register/RegisterConfiguration.kt | 4 +- .../register/RegisterFilterConfig.kt | 33 +++ .../data/local/register/RegisterRepository.kt | 7 +- .../engine/domain/model/DataQueryConfigs.kt | 23 +- .../domain/model/FhirResourceConfigs.kt | 4 + .../engine/domain/repository/Repository.kt | 2 + .../local/register/RegisterRepositoryTest.kt | 3 +- .../data/register/RegisterPagingSource.kt | 3 + .../ui/main/components/TopScreenSection.kt | 32 ++- .../ui/questionnaire/QuestionnaireActivity.kt | 18 +- .../questionnaire/QuestionnaireViewModel.kt | 2 +- .../quest/ui/register/RegisterEvent.kt | 2 + .../quest/ui/register/RegisterFilterState.kt | 25 ++ .../quest/ui/register/RegisterFragment.kt | 37 ++- .../quest/ui/register/RegisterScreen.kt | 8 +- .../quest/ui/register/RegisterUiState.kt | 1 + .../quest/ui/register/RegisterViewModel.kt | 236 ++++++++++++++- .../res/layout/questionnaire_activity.xml | 33 ++- android/quest/src/main/res/values/strings.xml | 1 + .../data/register/RegisterPagingSourceTest.kt | 81 +++++- .../quest/ui/register/RegisterFragmentTest.kt | 21 ++ .../ui/register/RegisterViewModelTest.kt | 270 +++++++++++++++++- 23 files changed, 792 insertions(+), 55 deletions(-) create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterFilterConfig.kt create mode 100644 android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFilterState.kt diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt index 437546ed37..0754b3a75a 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/QuestionnaireConfig.kt @@ -55,6 +55,7 @@ data class QuestionnaireConfig( null, val saveQuestionnaireResponse: Boolean = true, val generateCarePlanWithWorkflowApi: Boolean = false, + val showClearAll: Boolean = false, ) : java.io.Serializable, Parcelable { fun interpolate(computedValuesMap: Map) = diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterConfiguration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterConfiguration.kt index c10320735b..4a265dca84 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterConfiguration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterConfiguration.kt @@ -21,7 +21,6 @@ import org.hl7.fhir.r4.model.ResourceType import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.Configuration import org.smartregister.fhircore.engine.configuration.navigation.NavigationMenuConfig -import org.smartregister.fhircore.engine.domain.model.ActionConfig import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.RuleConfig @@ -33,7 +32,6 @@ data class RegisterConfiguration( val registerTitle: String? = null, val fhirResource: FhirResourceConfig, val secondaryResources: List? = null, - val filter: RegisterContentConfig? = null, val searchBar: RegisterContentConfig? = null, val registerCard: RegisterCardConfig = RegisterCardConfig(), val fabActions: List = emptyList(), @@ -45,5 +43,5 @@ data class RegisterConfiguration( ActiveResourceFilterConfig(resourceType = ResourceType.Group, active = true), ), val configRules: List? = null, - val filterActions: List = emptyList(), + val registerFilter: RegisterFilterConfig? = null, ) : Configuration() diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterFilterConfig.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterFilterConfig.kt new file mode 100644 index 0000000000..29a20f1ff8 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/register/RegisterFilterConfig.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2021-2023 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.engine.configuration.register + +import kotlinx.serialization.Serializable +import org.smartregister.fhircore.engine.domain.model.ActionConfig +import org.smartregister.fhircore.engine.domain.model.DataQuery + +@Serializable +data class RegisterFilterConfig( + val dataFilterActions: List? = null, + val dataFilterFields: List = emptyList(), +) : java.io.Serializable + +@Serializable +data class RegisterFilterField( + val dataQueries: List, + val filterId: String, +) : java.io.Serializable diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt index 14ffd3f939..3ab22221d7 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepository.kt @@ -67,12 +67,13 @@ constructor( override suspend fun loadRegisterData( currentPage: Int, registerId: String, + fhirResourceConfig: FhirResourceConfig?, paramsMap: Map?, ): List { val registerConfiguration = retrieveRegisterConfiguration(registerId, paramsMap) return searchResourcesRecursively( filterActiveResources = registerConfiguration.activeResourceFilters, - fhirResourceConfig = registerConfiguration.fhirResource, + fhirResourceConfig = fhirResourceConfig ?: registerConfiguration.fhirResource, secondaryResourceConfigs = registerConfiguration.secondaryResources, currentPage = currentPage, pageSize = registerConfiguration.pageSize, @@ -159,10 +160,12 @@ constructor( /** Count register data for the provided [registerId]. Use the configured base resource filters */ override suspend fun countRegisterData( registerId: String, + fhirResourceConfig: FhirResourceConfig?, paramsMap: Map?, ): Long { val registerConfiguration = retrieveRegisterConfiguration(registerId, paramsMap) - val baseResourceConfig = registerConfiguration.fhirResource.baseResource + val fhirResource = fhirResourceConfig ?: registerConfiguration.fhirResource + val baseResourceConfig = fhirResource.baseResource val configComputedRuleValues = registerConfiguration.configRules.configRulesComputedValues() val search = Search(baseResourceConfig.resource).apply { diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/DataQueryConfigs.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/DataQueryConfigs.kt index c9947c2853..726cc7b530 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/DataQueryConfigs.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/DataQueryConfigs.kt @@ -41,14 +41,17 @@ sealed class FilterCriterionConfig : Parcelable, java.io.Serializable { abstract val dataType: DataType abstract val computedRule: String? + abstract val dataFilterLinkId: String? + abstract val value: Any? @Serializable @Parcelize data class QuantityFilterCriterionConfig( override val dataType: DataType = DataType.QUANTITY, override val computedRule: String? = null, + override val dataFilterLinkId: String? = null, + @Serializable(with = BigDecimalSerializer::class) override val value: BigDecimal? = null, val prefix: ParamPrefixEnum? = null, - @Serializable(with = BigDecimalSerializer::class) val value: BigDecimal? = null, val system: String? = null, val unit: String? = null, ) : FilterCriterionConfig(), Parcelable, java.io.Serializable @@ -58,8 +61,9 @@ sealed class FilterCriterionConfig : Parcelable, java.io.Serializable { data class DateFilterCriterionConfig( override val dataType: DataType = DataType.DATETIME, override val computedRule: String? = null, + override val dataFilterLinkId: String? = null, + override val value: String? = null, val prefix: ParamPrefixEnum = ParamPrefixEnum.GREATERTHAN_OR_EQUALS, - val value: String? = null, val valueAsDateTime: Boolean = false, ) : FilterCriterionConfig(), Parcelable, java.io.Serializable @@ -68,8 +72,9 @@ sealed class FilterCriterionConfig : Parcelable, java.io.Serializable { data class NumberFilterCriterionConfig( override val dataType: DataType = DataType.DECIMAL, override val computedRule: String? = null, + override val dataFilterLinkId: String? = null, + @Serializable(with = BigDecimalSerializer::class) override val value: BigDecimal? = null, val prefix: ParamPrefixEnum = ParamPrefixEnum.EQUAL, - @Serializable(with = BigDecimalSerializer::class) val value: BigDecimal? = null, ) : FilterCriterionConfig(), Parcelable, java.io.Serializable @Serializable @@ -77,8 +82,9 @@ sealed class FilterCriterionConfig : Parcelable, java.io.Serializable { data class StringFilterCriterionConfig( override val dataType: DataType = DataType.STRING, override val computedRule: String? = null, + override val dataFilterLinkId: String? = null, + override val value: String? = null, val modifier: StringFilterModifier = StringFilterModifier.STARTS_WITH, - val value: String? = null, ) : FilterCriterionConfig(), Parcelable, java.io.Serializable @Serializable @@ -86,7 +92,8 @@ sealed class FilterCriterionConfig : Parcelable, java.io.Serializable { data class UriFilterCriterionConfig( override val dataType: DataType = DataType.URI, override val computedRule: String? = null, - val value: String? = null, + override val dataFilterLinkId: String? = null, + override val value: String? = null, ) : FilterCriterionConfig(), Parcelable, java.io.Serializable @Serializable @@ -94,7 +101,8 @@ sealed class FilterCriterionConfig : Parcelable, java.io.Serializable { data class ReferenceFilterCriterionConfig( override val dataType: DataType = DataType.REFERENCE, override val computedRule: String? = null, - val value: String? = null, + override val dataFilterLinkId: String? = null, + override val value: String? = null, ) : FilterCriterionConfig(), Parcelable, java.io.Serializable @Serializable @@ -102,6 +110,7 @@ sealed class FilterCriterionConfig : Parcelable, java.io.Serializable { data class TokenFilterCriterionConfig( override val dataType: DataType = DataType.CODE, override val computedRule: String? = null, - val value: Code? = null, + override val dataFilterLinkId: String? = null, + override val value: Code? = null, ) : FilterCriterionConfig(), Parcelable, java.io.Serializable } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/FhirResourceConfigs.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/FhirResourceConfigs.kt index 80bd6b77c2..dba9d50bd6 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/FhirResourceConfigs.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/model/FhirResourceConfigs.kt @@ -73,12 +73,16 @@ data class FhirResourceConfig( * [CountResultConfig.sumCounts] is set to true, all the related resources counts are computed once * via one query. However there may be scenarios to return count for each related resource e.g. for * every Patient in a Group, return their Tasks count. + * + * [filterId] Refers to a unique ID used to identify the Resource in data filter screen (The data + * filter screen renders a questionnaire with the linkIds for the content to be filtered) */ @Serializable @Parcelize data class ResourceConfig( val id: String? = null, val resource: ResourceType, + val filterId: String? = null, val searchParameter: String? = null, val isRevInclude: Boolean = true, val dataQueries: List? = null, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/repository/Repository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/repository/Repository.kt index 4481c5cee6..dd5882feec 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/repository/Repository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/domain/repository/Repository.kt @@ -32,6 +32,7 @@ interface Repository { suspend fun loadRegisterData( currentPage: Int, registerId: String, + fhirResourceConfig: FhirResourceConfig? = null, paramsMap: Map? = emptyMap(), ): List @@ -41,6 +42,7 @@ interface Repository { */ suspend fun countRegisterData( registerId: String, + fhirResourceConfig: FhirResourceConfig? = null, paramsMap: Map? = emptyMap(), ): Long diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepositoryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepositoryTest.kt index 4769007b49..b872cce558 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepositoryTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/RegisterRepositoryTest.kt @@ -158,7 +158,8 @@ class RegisterRepositoryTest : RobolectricTest() { val paramsMap = emptyMap() val searchSlot = slot() coEvery { fhirEngine.count(capture(searchSlot)) } returns 20 - val recordsCount = registerRepository.countRegisterData(PATIENT_REGISTER, paramsMap) + val recordsCount = + registerRepository.countRegisterData(registerId = PATIENT_REGISTER, paramsMap = paramsMap) Assert.assertEquals(ResourceType.Patient, searchSlot.captured.type) Assert.assertEquals(20, recordsCount) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt index 052cc9f362..2b67e1eda6 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSource.kt @@ -20,6 +20,7 @@ import android.database.SQLException import androidx.paging.PagingSource import androidx.paging.PagingState import org.smartregister.fhircore.engine.data.local.register.RegisterRepository +import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.RuleConfig import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor @@ -34,6 +35,7 @@ class RegisterPagingSource( private val registerRepository: RegisterRepository, private val resourceDataRulesExecutor: ResourceDataRulesExecutor, private val ruleConfigs: List, + private val fhirResourceConfig: FhirResourceConfig?, private val actionParameters: Map?, ) : PagingSource() { @@ -58,6 +60,7 @@ class RegisterPagingSource( registerRepository.loadRegisterData( currentPage = currentPage, registerId = _registerPagingSourceState.registerId, + fhirResourceConfig = fhirResourceConfig, ) val prevKey = diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/TopScreenSection.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/TopScreenSection.kt index cbb6a53688..1dd7cb7334 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/TopScreenSection.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/TopScreenSection.kt @@ -21,7 +21,10 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Badge +import androidx.compose.material.BadgedBox import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme @@ -41,6 +44,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.smartregister.fhircore.engine.R @@ -68,6 +72,7 @@ fun TopScreenSection( modifier: Modifier = Modifier, title: String, searchText: String, + filteredRecordsCount: Long? = null, searchPlaceholder: String? = null, toolBarHomeNavigation: ToolBarHomeNavigation = ToolBarHomeNavigation.OPEN_DRAWER, onSearchTextChanged: (String) -> Unit, @@ -100,12 +105,26 @@ fun TopScreenSection( ) if (isFilterIconEnabled) { IconButton(onClick = { onClick.invoke(ToolbarClickEvent.FilterData) }) { - Icon( - Icons.Filled.FilterAlt, - contentDescription = FILTER, - tint = Color.White, - modifier = modifier.testTag(TOP_ROW_FILTER_ICON_TEST_TAG), - ) + BadgedBox( + badge = { + if (filteredRecordsCount != null && filteredRecordsCount > -1) { + Badge(modifier = Modifier.size(18.dp)) { + Text( + text = filteredRecordsCount.toString(), + overflow = TextOverflow.Clip, + maxLines = 1, + ) + } + } + }, + ) { + Icon( + imageVector = Icons.Default.FilterAlt, + contentDescription = FILTER, + tint = Color.White, + modifier = modifier.testTag(TOP_ROW_FILTER_ICON_TEST_TAG), + ) + } } } } @@ -161,6 +180,7 @@ fun TopScreenSectionPreview() { TopScreenSection( title = "All Clients", searchText = "Eddy", + filteredRecordsCount = 8, onSearchTextChanged = {}, toolBarHomeNavigation = ToolBarHomeNavigation.NAVIGATE_BACK, isFilterIconEnabled = true, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt index e9a3a9169f..297f6abcbc 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt @@ -21,6 +21,7 @@ import android.app.AlertDialog import android.content.Intent import android.os.Bundle import android.os.Parcelable +import android.view.View import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.core.os.bundleOf @@ -114,10 +115,18 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { lifecycleScope.launch { if (supportFragmentManager.findFragmentByTag(QUESTIONNAIRE_FRAGMENT_TAG) == null) { viewModel.setProgressState(QuestionnaireProgressState.QuestionnaireLaunch(true)) - viewBinding.questionnaireToolbar.apply { - title = questionnaireConfig.title - setNavigationIcon(R.drawable.ic_arrow_back) - setNavigationOnClickListener { handleBackPress() } + with(viewBinding) { + questionnaireToolbar.apply { + setNavigationIcon(R.drawable.ic_arrow_back) + setNavigationOnClickListener { handleBackPress() } + } + questionnaireTitle.apply { text = questionnaireConfig.title } + clearAll.apply { + visibility = if (questionnaireConfig.showClearAll) View.VISIBLE else View.GONE + setOnClickListener { + // TODO Clear current QuestionnaireResponse items -> SDK + } + } } questionnaire = viewModel.retrieveQuestionnaire(questionnaireConfig, actionParameters) @@ -227,6 +236,7 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { context = this@QuestionnaireActivity, ) { idTypes, questionnaireResponse -> // Dismiss progress indicator dialog, submit result then finish activity + // TODO Ensure this dialog is dismissed even when an exception is encountered setProgressState(QuestionnaireProgressState.ExtractionInProgress(false)) setResult( Activity.RESULT_OK, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt index 80706cf950..1696b3a71b 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt @@ -189,7 +189,7 @@ constructor( context = context, ) - if (!questionnaireResponseValid) { + if (questionnaireConfig.saveQuestionnaireResponse && !questionnaireResponseValid) { Timber.e("Invalid questionnaire response") context.showToast(context.getString(R.string.questionnaire_response_invalid)) return@launch diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterEvent.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterEvent.kt index ed831df080..41f65114ce 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterEvent.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterEvent.kt @@ -22,4 +22,6 @@ sealed class RegisterEvent { object MoveToNextPage : RegisterEvent() object MoveToPreviousPage : RegisterEvent() + + object ResetFilterRecordsCount : RegisterEvent() } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFilterState.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFilterState.kt new file mode 100644 index 0000000000..0065303792 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFilterState.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2021-2023 Ona Systems, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.smartregister.fhircore.quest.ui.register + +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig + +data class RegisterFilterState( + val fhirResourceConfig: FhirResourceConfig? = null, + val questionnaireResponse: QuestionnaireResponse? = null, +) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFragment.kt index db5e4e30f6..de81653cfa 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterFragment.kt @@ -50,6 +50,7 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.QuestionnaireResponse import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig import org.smartregister.fhircore.engine.sync.OnSyncListener import org.smartregister.fhircore.engine.sync.SyncListenerManager @@ -244,11 +245,15 @@ class RegisterFragment : Fragment(), OnSyncListener { } } - fun refreshRegisterData() { + fun refreshRegisterData(questionnaireResponse: QuestionnaireResponse? = null) { with(registerFragmentArgs) { registerViewModel.run { - // Clear pages cache to load new data + if (questionnaireResponse != null) { + updateRegisterFilterState(registerId, questionnaireResponse) + } + pagesDataCache.clear() + retrieveRegisterUiState( registerId = registerId, screenTitle = screenTitle, @@ -277,20 +282,24 @@ class RegisterFragment : Fragment(), OnSyncListener { } suspend fun handleQuestionnaireSubmission(questionnaireSubmission: QuestionnaireSubmission) { - appMainViewModel.run { - onQuestionnaireSubmission(questionnaireSubmission) - retrieveAppMainUiState(refreshAll = false) // Update register counts - } + if (questionnaireSubmission.questionnaireConfig.saveQuestionnaireResponse) { + appMainViewModel.run { + onQuestionnaireSubmission(questionnaireSubmission) + retrieveAppMainUiState(refreshAll = false) // Update register counts + } - val (questionnaireConfig, _) = questionnaireSubmission + val (questionnaireConfig, _) = questionnaireSubmission - refreshRegisterData() + refreshRegisterData() - questionnaireConfig.snackBarMessage?.let { snackBarMessageConfig -> - registerViewModel.emitSnackBarState(snackBarMessageConfig) - } + questionnaireConfig.snackBarMessage?.let { snackBarMessageConfig -> + registerViewModel.emitSnackBarState(snackBarMessageConfig) + } - questionnaireConfig.onSubmitActions?.handleClickEvent(navController = findNavController()) + questionnaireConfig.onSubmitActions?.handleClickEvent(navController = findNavController()) + } else { + refreshRegisterData(questionnaireSubmission.questionnaireResponse) + } } fun emitPercentageProgress( @@ -316,8 +325,8 @@ class RegisterFragment : Fragment(), OnSyncListener { 1L, ) val isProgressTotalLess = progressSyncJobStatus.total <= totalRecordsOverall - var currentProgress: Int - var currentTotalRecords = + val currentProgress: Int + val currentTotalRecords = if (isProgressTotalLess) { currentProgress = totalRecordsOverall.toInt() - progressSyncJobStatus.total + 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 08fae72e12..fd4fbe2c31 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 @@ -79,10 +79,11 @@ fun RegisterScreen( topBar = { Column { // Top section has toolbar and a results counts view - val filterActions = registerUiState.registerConfiguration?.filterActions + val filterActions = registerUiState.registerConfiguration?.registerFilter?.dataFilterActions TopScreenSection( title = registerUiState.screenTitle, searchText = searchText.value, + filteredRecordsCount = registerUiState.filteredRecordsCount, searchPlaceholder = registerUiState.registerConfiguration?.searchBar?.display, toolBarHomeNavigation = toolBarHomeNavigation, onSearchTextChanged = { searchText -> @@ -96,7 +97,10 @@ fun RegisterScreen( ToolBarHomeNavigation.OPEN_DRAWER -> openDrawer(true) ToolBarHomeNavigation.NAVIGATE_BACK -> navController.popBackStack() } - ToolbarClickEvent.FilterData -> filterActions?.handleClickEvent(navController) + ToolbarClickEvent.FilterData -> { + onEvent(RegisterEvent.ResetFilterRecordsCount) + filterActions?.handleClickEvent(navController) + } } } // Only show counter during search 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 8849dbf0e0..aacf5c7ca5 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 @@ -26,6 +26,7 @@ data class RegisterUiState( val registerConfiguration: RegisterConfiguration? = null, val registerId: String = "", val totalRecordsCount: Long = 0, + val filteredRecordsCount: Long = 0, val pagesCount: Int = 1, val progressPercentage: Flow = flowOf(0), val isSyncUpload: Flow = flowOf(false), 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 aef3057b91..b77dd53778 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,8 @@ package org.smartregister.fhircore.quest.ui.register import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -35,20 +37,31 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Enumerations.DataType +import org.hl7.fhir.r4.model.QuestionnaireResponse import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration +import org.smartregister.fhircore.engine.configuration.register.RegisterFilterField import org.smartregister.fhircore.engine.data.local.register.RegisterRepository import org.smartregister.fhircore.engine.domain.model.ActionParameter +import org.smartregister.fhircore.engine.domain.model.Code +import org.smartregister.fhircore.engine.domain.model.DataQuery +import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig +import org.smartregister.fhircore.engine.domain.model.FilterCriterionConfig +import org.smartregister.fhircore.engine.domain.model.ResourceConfig import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey 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.util.extensions.toParamDataMap +import timber.log.Timber @HiltViewModel class RegisterViewModel @@ -64,12 +77,14 @@ constructor( private val _snackBarStateFlow = MutableSharedFlow() val snackBarStateFlow = _snackBarStateFlow.asSharedFlow() val registerUiState = mutableStateOf(RegisterUiState()) - val currentPage: MutableState = mutableStateOf(0) + val currentPage: MutableState = mutableIntStateOf(0) val searchText = mutableStateOf("") val paginatedRegisterData: MutableStateFlow>> = MutableStateFlow(emptyFlow()) val pagesDataCache = mutableMapOf>>() - private val _totalRecordsCount = mutableStateOf(0L) + val registerFilterState = mutableStateOf(RegisterFilterState()) + private val _totalRecordsCount = mutableLongStateOf(0L) + private val _filteredRecordsCount = mutableLongStateOf(-1L) private lateinit var registerConfiguration: RegisterConfiguration private var allPatientRegisterData: Flow>? = null private val _percentageProgress: MutableSharedFlow = MutableSharedFlow(0) @@ -107,6 +122,7 @@ constructor( registerRepository = registerRepository, resourceDataRulesExecutor = resourceDataRulesExecutor, ruleConfigs = ruleConfigs, + fhirResourceConfig = registerFilterState.value.fhirResourceConfig, actionParameters = registerUiState.value.params, ) .apply { @@ -161,6 +177,7 @@ constructor( currentPage.value.let { if (it > 0) currentPage.value = it.minus(1) } paginateRegisterData(registerUiState.value.registerId) } + RegisterEvent.ResetFilterRecordsCount -> _filteredRecordsCount.value = -1 } fun filterRegisterData(event: RegisterEvent.SearchRegister) { @@ -181,6 +198,204 @@ constructor( } } + fun updateRegisterFilterState(registerId: String, questionnaireResponse: QuestionnaireResponse) { + // Reset filter state if no answer is provided for all the fields + if (questionnaireResponse.item.all { !it.hasAnswer() }) { + registerFilterState.value = + RegisterFilterState( + questionnaireResponse = null, + fhirResourceConfig = null, + ) + return + } + + val registerConfiguration = retrieveRegisterConfiguration(registerId) + val resourceConfig = registerConfiguration.fhirResource + val baseResource = resourceConfig.baseResource + val qrItemMap = questionnaireResponse.item.groupBy { it.linkId }.mapValues { it.value.first() } + + val registerDataFilterFieldsMap = + registerConfiguration.registerFilter + ?.dataFilterFields + ?.groupBy { it.filterId } + ?.mapValues { it.value.first() } + + // Get filter queries from the map. NOTE: filterId MUST be unique for all resources + val newBaseResourceDataQueries = + createQueriesForRegisterFilter( + registerDataFilterFieldsMap?.get(baseResource.filterId)?.dataQueries + ?: baseResource.dataQueries, + qrItemMap, + ) + + Timber.i( + "New data queries for filtering Base Resources: ${newBaseResourceDataQueries.encodeJson()}", + ) + + val newRelatedResources = + createFilterRelatedResources( + registerDataFilterFieldsMap = registerDataFilterFieldsMap, + relatedResources = resourceConfig.relatedResources, + qrItemMap = qrItemMap, + ) + + Timber.i( + "New configurations for filtering related resource data: ${newRelatedResources.encodeJson()}", + ) + + registerFilterState.value = + RegisterFilterState( + questionnaireResponse = questionnaireResponse, + fhirResourceConfig = + FhirResourceConfig( + baseResource = baseResource.copy(dataQueries = newBaseResourceDataQueries), + relatedResources = newRelatedResources, + ), + ) + } + + private fun createFilterRelatedResources( + registerDataFilterFieldsMap: Map?, + relatedResources: List, + qrItemMap: Map, + ): List { + val newRelatedResources = + relatedResources.map { + val newDataQueries = + createQueriesForRegisterFilter( + registerDataFilterFieldsMap?.get(it.filterId)?.dataQueries ?: it.dataQueries, + qrItemMap, + ) + it.copy( + dataQueries = newDataQueries, + relatedResources = + createFilterRelatedResources( + registerDataFilterFieldsMap = registerDataFilterFieldsMap, + relatedResources = it.relatedResources, + qrItemMap = qrItemMap, + ), + ) + } + return newRelatedResources + } + + private fun createQueriesForRegisterFilter( + dataQueries: List?, + qrItemMap: Map, + ) = + dataQueries?.map { + val newFilterCriteria = mutableListOf() + it.filterCriteria.forEach { filterCriterionConfig -> + val answerComponent = qrItemMap[filterCriterionConfig.dataFilterLinkId] + answerComponent?.answer?.forEach { itemAnswerComponent -> + val criterion = convertAnswerToFilterCriterion(itemAnswerComponent, filterCriterionConfig) + if (criterion != null) newFilterCriteria.add(criterion) + } + } + it.copy( + filterCriteria = if (newFilterCriteria.isEmpty()) it.filterCriteria else newFilterCriteria, + ) + } + + private fun convertAnswerToFilterCriterion( + answerComponent: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, + oldFilterCriterion: FilterCriterionConfig, + ): FilterCriterionConfig? = + when { + answerComponent.hasValueCoding() -> { + val valueCoding: Coding = answerComponent.valueCoding + FilterCriterionConfig.TokenFilterCriterionConfig( + dataType = DataType.CODE, + computedRule = oldFilterCriterion.computedRule, + value = Code(valueCoding.system, valueCoding.code, valueCoding.display), + ) + } + answerComponent.hasValueStringType() -> { + val stringFilterCriterion = + oldFilterCriterion as FilterCriterionConfig.StringFilterCriterionConfig + FilterCriterionConfig.StringFilterCriterionConfig( + dataType = DataType.STRING, + computedRule = stringFilterCriterion.computedRule, + modifier = stringFilterCriterion.modifier, + value = answerComponent.valueStringType.value, + ) + } + answerComponent.hasValueQuantity() -> { + val quantityCriteria = + oldFilterCriterion as FilterCriterionConfig.QuantityFilterCriterionConfig + FilterCriterionConfig.QuantityFilterCriterionConfig( + dataType = DataType.QUANTITY, + computedRule = quantityCriteria.computedRule, + prefix = quantityCriteria.prefix, + system = quantityCriteria.system, + unit = quantityCriteria.unit, + value = answerComponent.valueDecimalType.value, + ) + } + answerComponent.hasValueIntegerType() -> { + val numberFilterCriterion = + oldFilterCriterion as FilterCriterionConfig.NumberFilterCriterionConfig + FilterCriterionConfig.NumberFilterCriterionConfig( + dataType = DataType.DECIMAL, + computedRule = numberFilterCriterion.computedRule, + prefix = numberFilterCriterion.prefix, + value = answerComponent.valueIntegerType.value.toBigDecimal(), + ) + } + answerComponent.hasValueDecimalType() -> { + val numberFilterCriterion = + oldFilterCriterion as FilterCriterionConfig.NumberFilterCriterionConfig + FilterCriterionConfig.NumberFilterCriterionConfig( + dataType = DataType.DECIMAL, + computedRule = numberFilterCriterion.computedRule, + prefix = numberFilterCriterion.prefix, + value = answerComponent.valueDecimalType.value, + ) + } + answerComponent.hasValueDateTimeType() -> { + val dateFilterCriterion = + oldFilterCriterion as FilterCriterionConfig.DateFilterCriterionConfig + FilterCriterionConfig.DateFilterCriterionConfig( + dataType = DataType.DATETIME, + computedRule = dateFilterCriterion.computedRule, + prefix = dateFilterCriterion.prefix, + valueAsDateTime = true, + value = answerComponent.valueDecimalType.asStringValue(), + ) + } + answerComponent.hasValueDateType() -> { + val dateFilterCriterion = + oldFilterCriterion as FilterCriterionConfig.DateFilterCriterionConfig + FilterCriterionConfig.DateFilterCriterionConfig( + dataType = DataType.DATE, + computedRule = dateFilterCriterion.computedRule, + prefix = dateFilterCriterion.prefix, + valueAsDateTime = false, + value = answerComponent.valueDateType.asStringValue(), + ) + } + answerComponent.hasValueUriType() -> { + val uriCriterion = oldFilterCriterion as FilterCriterionConfig.UriFilterCriterionConfig + FilterCriterionConfig.UriFilterCriterionConfig( + dataType = DataType.URI, + computedRule = uriCriterion.computedRule, + value = answerComponent.valueUriType.valueAsString, + ) + } + answerComponent.hasValueReference() -> { + val referenceCriterion = + oldFilterCriterion as FilterCriterionConfig.ReferenceFilterCriterionConfig + FilterCriterionConfig.ReferenceFilterCriterionConfig( + dataType = DataType.REFERENCE, + computedRule = referenceCriterion.computedRule, + value = answerComponent.valueReference.reference, + ) + } + else -> { + null + } + } + fun retrieveRegisterUiState( registerId: String, screenTitle: String, @@ -191,8 +406,20 @@ constructor( val paramsMap: Map = params.toParamDataMap() viewModelScope.launch(dispatcherProvider.io()) { val currentRegisterConfiguration = retrieveRegisterConfiguration(registerId, paramsMap) - // Count register data then paginate the data - _totalRecordsCount.value = registerRepository.countRegisterData(registerId, paramsMap) + + _totalRecordsCount.value = + registerRepository.countRegisterData(registerId = registerId, paramsMap = paramsMap) + + // Only count filtered data when queries are updated + if (registerFilterState.value.fhirResourceConfig != null) { + _filteredRecordsCount.value = + registerRepository.countRegisterData( + registerId = registerId, + paramsMap = paramsMap, + fhirResourceConfig = registerFilterState.value.fhirResourceConfig, + ) + } + paginateRegisterData(registerId, loadAll = false, clearCache = clearCache) registerUiState.value = @@ -205,6 +432,7 @@ constructor( registerConfiguration = currentRegisterConfiguration, registerId = registerId, totalRecordsCount = _totalRecordsCount.value, + filteredRecordsCount = _filteredRecordsCount.value, pagesCount = ceil( _totalRecordsCount.value diff --git a/android/quest/src/main/res/layout/questionnaire_activity.xml b/android/quest/src/main/res/layout/questionnaire_activity.xml index 8ad4b2d76d..ff75fb4e91 100644 --- a/android/quest/src/main/res/layout/questionnaire_activity.xml +++ b/android/quest/src/main/res/layout/questionnaire_activity.xml @@ -13,7 +13,38 @@ android:layout_height="wrap_content" android:background="@color/colorPrimary" android:minHeight="?android:attr/actionBarSize" - app:titleTextColor="@color/white" /> + app:titleTextColor="@color/white"> + + + + + + + + Error populating some questionnaire fields. Invalid QuestionnaireResponse. Processing questionnaire data… Loading questionnaire… + Clear All diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSourceTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSourceTest.kt index f44406a3b1..d1eba6cfa7 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSourceTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/data/register/RegisterPagingSourceTest.kt @@ -24,13 +24,18 @@ import io.mockk.every import io.mockk.mockk import javax.inject.Inject import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.ResourceType +import org.hl7.fhir.r4.model.Task import org.junit.Assert import org.junit.Before import org.junit.Rule import org.junit.Test import org.smartregister.fhircore.engine.data.local.register.RegisterRepository +import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig import org.smartregister.fhircore.engine.domain.model.RepositoryResourceData +import org.smartregister.fhircore.engine.domain.model.ResourceConfig import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor +import org.smartregister.fhircore.engine.util.extension.asReference import org.smartregister.fhircore.quest.app.fakes.Faker import org.smartregister.fhircore.quest.data.register.model.RegisterPagingSourceState import org.smartregister.fhircore.quest.robolectric.RobolectricTest @@ -48,12 +53,18 @@ class RegisterPagingSourceTest : RobolectricTest() { @Before fun setUp() { hiltAndroidRule.inject() - registerPagingSource = - RegisterPagingSource(registerRepository, resourceDataRulesExecutor, listOf(), emptyMap()) } @Test - fun testLoadShouldReturnResults() { + fun testLoadWithNullFhirResourceConfigShouldReturnResults() { + registerPagingSource = + RegisterPagingSource( + registerRepository = registerRepository, + resourceDataRulesExecutor = resourceDataRulesExecutor, + ruleConfigs = listOf(), + actionParameters = emptyMap(), + fhirResourceConfig = null, + ) coEvery { registerRepository.loadRegisterData(0, registerId) } returns listOf(RepositoryResourceData(resource = Faker.buildPatient())) @@ -70,4 +81,68 @@ class RegisterPagingSourceTest : RobolectricTest() { } } } + + @Test + fun testLoadWithProvidedFhirResourceConfigShouldReturnResults() { + val baseResource = Faker.buildPatient() + val relatedResources = + listOf( + Task().apply { + id = "hiv-test-task" + status = Task.TaskStatus.READY + description = "Test patient HIV status" + `for` = baseResource.asReference() + }, + ) + + val fhirResourceConfig = + FhirResourceConfig( + baseResource = + ResourceConfig( + id = ResourceType.Patient.name, + resource = ResourceType.Patient, + ), + relatedResources = + listOf( + ResourceConfig( + id = ResourceType.Task.name, + resource = ResourceType.Task, + ), + ), + ) + registerPagingSource = + RegisterPagingSource( + registerRepository = registerRepository, + resourceDataRulesExecutor = resourceDataRulesExecutor, + ruleConfigs = listOf(), + actionParameters = emptyMap(), + fhirResourceConfig = fhirResourceConfig, + ) + coEvery { + registerRepository.loadRegisterData( + currentPage = 0, + registerId = registerId, + fhirResourceConfig = fhirResourceConfig, + ) + } returns + listOf( + RepositoryResourceData( + resource = baseResource, + relatedResourcesMap = relatedResources.groupBy { it.resourceType.name }, + ), + ) + + val loadParams = mockk>() + every { loadParams.key } returns null + runBlocking { + registerPagingSource.run { + setPatientPagingSourceState( + RegisterPagingSourceState(registerId = registerId, currentPage = 0, loadAll = false), + ) + val result = load(loadParams) + Assert.assertNotNull(result) + Assert.assertEquals(1, (result as PagingSource.LoadResult.Page).data.size) + } + } + } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterFragmentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterFragmentTest.kt index cc7531638d..def621bc6c 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterFragmentTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/register/RegisterFragmentTest.kt @@ -34,6 +34,7 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.runs +import io.mockk.slot import io.mockk.spyk import io.mockk.verify import javax.inject.Inject @@ -244,6 +245,26 @@ class RegisterFragmentTest : RobolectricTest() { coVerify { registerViewModel.emitSnackBarState(snackBarMessageConfig) } } + @Test + fun testHandleQuestionnaireSubmissionUpdatesFilterResourceConfig() { + val questionnaireConfig = + QuestionnaireConfig(id = "add-member", saveQuestionnaireResponse = false) + val questionnaireResponse = QuestionnaireResponse().apply { id = "1234" } + val questionnaireSubmission = + QuestionnaireSubmission( + questionnaireConfig = questionnaireConfig, + questionnaireResponse = questionnaireResponse, + ) + val registerFragmentSpy = spyk(registerFragment) + + runBlocking { registerFragmentSpy.handleQuestionnaireSubmission(questionnaireSubmission) } + + // Refresh data is called with QuestionnaireResponse param + val submissionSlot = slot() + coVerify { registerFragmentSpy.refreshRegisterData(capture(submissionSlot)) } + Assert.assertEquals(questionnaireResponse.id, submissionSlot.captured.id) + } + @Test fun testOnSyncWithFailedJobStatusNonAuthErrorRendersSyncFailedMessage() { val syncJobStatus = 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 e3eb0c0a91..6f6cff3858 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 @@ -17,6 +17,8 @@ package org.smartregister.fhircore.quest.ui.register import androidx.compose.runtime.mutableStateOf +import ca.uhn.fhir.rest.param.ParamPrefixEnum +import com.google.android.fhir.search.StringFilterModifier import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.coEvery @@ -28,15 +30,29 @@ import io.mockk.spyk import io.mockk.verify import javax.inject.Inject import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.DateType +import org.hl7.fhir.r4.model.Enumerations +import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.ResourceType +import org.hl7.fhir.r4.model.StringType import org.junit.Assert import org.junit.Before import org.junit.Rule import org.junit.Test import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration +import org.smartregister.fhircore.engine.configuration.register.RegisterFilterConfig +import org.smartregister.fhircore.engine.configuration.register.RegisterFilterField +import org.smartregister.fhircore.engine.configuration.workflow.ActionTrigger +import org.smartregister.fhircore.engine.configuration.workflow.ApplicationWorkflow import org.smartregister.fhircore.engine.data.local.register.RegisterRepository +import org.smartregister.fhircore.engine.domain.model.ActionConfig +import org.smartregister.fhircore.engine.domain.model.Code +import org.smartregister.fhircore.engine.domain.model.DataQuery import org.smartregister.fhircore.engine.domain.model.FhirResourceConfig +import org.smartregister.fhircore.engine.domain.model.FilterCriterionConfig import org.smartregister.fhircore.engine.domain.model.ResourceConfig import org.smartregister.fhircore.engine.rulesengine.ResourceDataRulesExecutor import org.smartregister.fhircore.engine.util.SharedPreferenceKey @@ -73,6 +89,13 @@ class RegisterViewModelTest : RobolectricTest() { ), ) + every { + sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null) + } returns "Mar 20, 03:01PM" + } + + @Test + fun testPaginateRegisterData() { every { registerViewModel.retrieveRegisterConfiguration(any()) } returns RegisterConfiguration( appId = "app", @@ -81,13 +104,6 @@ class RegisterViewModelTest : RobolectricTest() { FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Patient)), pageSize = 10, ) - every { - sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null) - } returns "Mar 20, 03:01PM" - } - - @Test - fun testPaginateRegisterData() { registerViewModel.paginateRegisterData(registerId, false) val paginatedRegisterData = registerViewModel.paginatedRegisterData.value Assert.assertNotNull(paginatedRegisterData) @@ -97,6 +113,14 @@ class RegisterViewModelTest : RobolectricTest() { @Test @kotlinx.coroutines.ExperimentalCoroutinesApi fun testRetrieveRegisterUiState() = runTest { + every { registerViewModel.retrieveRegisterConfiguration(any()) } returns + RegisterConfiguration( + appId = "app", + id = registerId, + fhirResource = + FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Patient)), + pageSize = 10, + ) every { registerViewModel.paginateRegisterData(any(), any()) } just runs coEvery { registerRepository.countRegisterData(any()) } returns 200 registerViewModel.retrieveRegisterUiState( @@ -119,6 +143,14 @@ class RegisterViewModelTest : RobolectricTest() { @Test fun testOnEventSearchRegister() { + every { registerViewModel.retrieveRegisterConfiguration(any()) } returns + RegisterConfiguration( + appId = "app", + id = registerId, + fhirResource = + FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Patient)), + pageSize = 10, + ) every { registerViewModel.registerUiState } returns mutableStateOf(RegisterUiState(registerId = registerId)) // Search with empty string should paginate the data @@ -151,4 +183,228 @@ class RegisterViewModelTest : RobolectricTest() { Assert.assertEquals(1, registerViewModel.currentPage.value) verify { registerViewModel.paginateRegisterData(any(), any()) } } + + // TODO Test (CHT) remaining scenarios (e.g. filter by quantity, reference, number, datetime etc) + @Test + fun testUpdateRegisterFilterStateShouldUpdateFilterState() { + // Get state before the resource config is updated + val registerFilterStateBefore = registerViewModel.registerFilterState.value + + val questionnaireConfig = + QuestionnaireConfig(id = "register-patient", saveQuestionnaireResponse = false) + + val fhirResourceConfig = filterFhirResourceConfig() + + // QuestionnaireResponse linkId should match the register filter field IDs + val questionnaireResponse = + QuestionnaireResponse().apply { + id = "1234" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "gender-filter" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = StringType("female") + }, + ) + }, + ) + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "birthdate-filter" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType("2000-01-01") + }, + ) + }, + ) + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "task-status-filter" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = Coding("http://hl7.org/fhir/task-status", "ready", "Task status") + }, + ) + }, + ) + } + + val dataFilterFields = registerFilterFields() + every { registerViewModel.retrieveRegisterConfiguration(registerId) } returns + RegisterConfiguration( + appId = "app", + id = registerId, + fhirResource = fhirResourceConfig, + pageSize = 10, + registerFilter = + RegisterFilterConfig( + dataFilterActions = + listOf( + ActionConfig( + trigger = ActionTrigger.ON_CLICK, + workflow = ApplicationWorkflow.LAUNCH_QUESTIONNAIRE.name, + questionnaire = questionnaireConfig, + ), + ), + dataFilterFields = dataFilterFields, + ), + ) + registerViewModel.updateRegisterFilterState( + registerId = registerId, + questionnaireResponse = questionnaireResponse, + ) + + val registerFilterStateAfter = registerViewModel.registerFilterState.value + Assert.assertNull(registerFilterStateBefore.fhirResourceConfig) + val updatedFhirResourceConfig = registerFilterStateAfter.fhirResourceConfig + Assert.assertNotNull(updatedFhirResourceConfig) + + // Data queries assertions to confirm that the queries were updated. + // If no queries existed before filter then new ones will be created. + val newBaseResourceQueries = updatedFhirResourceConfig?.baseResource?.dataQueries + Assert.assertNotNull(newBaseResourceQueries) + Assert.assertNotNull( + newBaseResourceQueries!!.find { dataQuery -> + dataQuery.paramName == "gender" && + dataQuery.filterCriteria.any { + it.dataType == Enumerations.DataType.STRING && it.value == "female" + } + }, + ) + + Assert.assertNotNull( + newBaseResourceQueries.find { dataQuery -> + dataQuery.paramName == "birthdate" && + dataQuery.filterCriteria.any { + it.dataType == Enumerations.DataType.DATE && it.value == "2000-01-01" + } + }, + ) + + val taskRelatedResourceDataQueries = + updatedFhirResourceConfig.relatedResources + .find { it.id == ResourceType.Task.name } + ?.dataQueries + Assert.assertNotNull(taskRelatedResourceDataQueries) + Assert.assertNotNull( + taskRelatedResourceDataQueries!!.find { dataQuery -> + dataQuery.paramName == "status" && + dataQuery.filterCriteria.any { + it.dataType == Enumerations.DataType.CODE && (it.value as Code).code == "ready" + } + }, + ) + } + + private fun registerFilterFields() = + listOf( + RegisterFilterField( + filterId = ResourceType.Patient.name, + dataQueries = + listOf( + DataQuery( + paramName = "gender", + filterCriteria = + listOf( + FilterCriterionConfig.StringFilterCriterionConfig( + dataType = Enumerations.DataType.STRING, + modifier = StringFilterModifier.MATCHES_EXACTLY, + dataFilterLinkId = "gender-filter", + ), + ), + ), + DataQuery( + paramName = "birthdate", + filterCriteria = + listOf( + FilterCriterionConfig.DateFilterCriterionConfig( + dataType = Enumerations.DataType.DATE, + prefix = ParamPrefixEnum.LESSTHAN_OR_EQUALS, + dataFilterLinkId = "birthdate-filter", + ), + ), + ), + ), + ), + RegisterFilterField( + filterId = ResourceType.Task.name, + dataQueries = + listOf( + DataQuery( + paramName = "status", + filterCriteria = + listOf( + FilterCriterionConfig.TokenFilterCriterionConfig( + dataType = Enumerations.DataType.CODE, + dataFilterLinkId = "task-status-filter", + ), + ), + ), + ), + ), + ) + + private fun filterFhirResourceConfig() = + FhirResourceConfig( + baseResource = + ResourceConfig( + id = ResourceType.Patient.name, + resource = ResourceType.Patient, + filterId = ResourceType.Patient.name, + ), + relatedResources = + listOf( + ResourceConfig( + id = ResourceType.Task.name, + resource = ResourceType.Task, + filterId = ResourceType.Task.name, + ), + ), + ) + + @Test + fun testUpdateRegisterFilterStateWithNoAnswersForFilterQR() { + val registerFilterStateBefore = registerViewModel.registerFilterState + val questionnaireResponse = + QuestionnaireResponse().apply { + id = "1234" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "gender-filter" + }, + ) + } + val fhirResourceConfig = filterFhirResourceConfig() + val dataFilterFields = registerFilterFields() + val questionnaireConfig = + QuestionnaireConfig(id = "register-patient", saveQuestionnaireResponse = false) + every { registerViewModel.retrieveRegisterConfiguration(any()) } returns + RegisterConfiguration( + appId = "app", + id = registerId, + fhirResource = fhirResourceConfig, + pageSize = 10, + registerFilter = + RegisterFilterConfig( + dataFilterActions = + listOf( + ActionConfig( + trigger = ActionTrigger.ON_CLICK, + workflow = ApplicationWorkflow.LAUNCH_QUESTIONNAIRE.name, + questionnaire = questionnaireConfig, + ), + ), + dataFilterFields = dataFilterFields, + ), + ) + + registerViewModel.updateRegisterFilterState(registerId, questionnaireResponse) + + val registerFilterStateAfter = registerViewModel.registerFilterState + + Assert.assertNull(registerFilterStateBefore.value.fhirResourceConfig?.baseResource?.dataQueries) + Assert.assertNull(registerFilterStateAfter.value.fhirResourceConfig?.baseResource?.dataQueries) + } }