From c08e6e1348a64dc3aff5974ce387108284aa39f3 Mon Sep 17 00:00:00 2001 From: Benjamin Mwalimu Date: Fri, 15 Nov 2024 15:54:32 +0300 Subject: [PATCH] Fix enhancements conflicts (#3612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐛 Fix conflicts --- CHANGELOG.md | 1 + LICENSE | 2 +- .../configuration/ConfigurationRegistry.kt | 2 +- .../register/RegisterContentConfig.kt | 1 + .../view/CompoundTextProperties.kt | 1 + .../fhircore/engine/di/FhirValidatorModule.kt | 12 + .../fhircore/engine/ui/base/AlertDialogue.kt | 9 + .../engine/util/KnowledgeManagerUtil.kt | 16 +- .../util/extension/FhirValidatorExtension.kt | 76 - .../util/extension/QuestionnaireExtension.kt | 17 + .../validation/ResourceValidationRequest.kt | 94 + .../engine/src/main/res/values/strings.xml | 11 +- .../ConfigurationRegistryTest.kt | 2 +- .../engine/ui/base/AlertDialogueTest.kt | 2 + .../engine/util/KnowledgeManagerUtilTest.kt | 75 + .../extension/QuestionnaireExtensionTest.kt | 17 + .../ResourceValidationRequestTest.kt} | 54 +- android/gradle/libs.versions.toml | 13 +- .../fhircore/quest/integration/Faker.kt | 9 +- .../main/components/TopScreenSectionTest.kt | 45 + .../ui/register/RegisterScreenTest.kt | 150 +- .../components/RegisterCardListTest.kt | 5 + .../ui/shared/components/CompoundTextTest.kt | 3 + .../ui/main/components/TopScreenSection.kt | 3 +- .../ui/questionnaire/QuestionnaireActivity.kt | 84 +- .../questionnaire/QuestionnaireViewModel.kt | 2105 +++++++++-------- .../quest/ui/register/RegisterFragment.kt | 17 +- .../quest/ui/register/RegisterScreen.kt | 16 +- .../quest/ui/register/RegisterUiCountState.kt | 23 + .../quest/ui/register/RegisterUiState.kt | 3 - .../quest/ui/register/RegisterViewModel.kt | 172 +- .../register/components/RegisterCardList.kt | 4 +- .../ui/shared/components/CompoundText.kt | 5 +- .../src/main/res/drawable/ic_qr_code.xml | 11 +- .../QuestionnaireViewModelTest.kt | 142 +- .../quest/ui/register/RegisterFragmentTest.kt | 4 + .../ui/register/RegisterViewModelTest.kt | 81 +- docs/engineering/admin-dashboard/readme.mdx | 18 +- .../configuring/forms/save-form-as-draft.mdx | 55 + docs/engineering/app/datastore/tagging.mdx | 2 +- .../readme.mdx} | 0 .../backend/info-gateway/sync-strategies.mdx | 57 + .../admin-dashboard-features/readme.mdx | 20 +- 43 files changed, 2103 insertions(+), 1336 deletions(-) delete mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirValidatorExtension.kt create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/util/validation/ResourceValidationRequest.kt create mode 100644 android/engine/src/test/java/org/smartregister/fhircore/engine/util/KnowledgeManagerUtilTest.kt rename android/engine/src/test/java/org/smartregister/fhircore/engine/util/{extension/FhirValidatorExtensionTest.kt => validation/ResourceValidationRequestTest.kt} (65%) create mode 100644 android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiCountState.kt create mode 100644 docs/engineering/app/configuring/forms/save-form-as-draft.mdx rename docs/engineering/backend/{info-gateway.mdx => info-gateway/readme.mdx} (100%) create mode 100644 docs/engineering/backend/info-gateway/sync-strategies.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 427f2bae606..6b6de709108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 1. Added a new class (PdfGenerator) for generating PDF documents from HTML content using Android's WebView and PrintManager 2. Introduced a new class (HtmlPopulator) to populate HTML templates with data from a Questionnaire Response 3. Implemented functionality to launch PDF generation using a configuration setup +- Added Save draft MVP functionality ## [1.1.0] - 2024-02-15 diff --git a/LICENSE b/LICENSE index 7c07594ee6f..301d5cd1996 100644 --- a/LICENSE +++ b/LICENSE @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2021-2023, Ona Systems, Inc. + Copyright 2021-2024, 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. diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt index 6fd3ab17a40..db5c35d8a90 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt @@ -613,7 +613,7 @@ constructor( context = context, configService = configService, metadataResource = resource, - filePath = + subFilePath = "${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/${resource.resourceType}/${resource.idElement.idPart}.json", ), ) 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 4dc64dd3e86..43ba2ab4a13 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 @@ -23,6 +23,7 @@ import org.smartregister.fhircore.engine.domain.model.RuleConfig data class RegisterContentConfig( val separator: String? = null, val display: String? = null, + val placeholderColor: String? = null, val rules: List? = null, val visible: Boolean? = null, val computedRules: List? = null, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/CompoundTextProperties.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/CompoundTextProperties.kt index a81b7a3b247..928a67a9181 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/CompoundTextProperties.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/view/CompoundTextProperties.kt @@ -55,6 +55,7 @@ data class CompoundTextProperties( val textCase: TextCase? = null, val overflow: TextOverFlow? = null, val letterSpacing: Int = 0, + val textInnerPadding: Int = 0, ) : ViewProperties(), Parcelable { override fun interpolate(computedValuesMap: Map): CompoundTextProperties { return this.copy( diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirValidatorModule.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirValidatorModule.kt index 53af79a8a9b..4e49ded6ba7 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirValidatorModule.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirValidatorModule.kt @@ -20,6 +20,7 @@ import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.support.DefaultProfileValidationSupport import ca.uhn.fhir.context.support.IValidationSupport import ca.uhn.fhir.validation.FhirValidator +import dagger.Lazy import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -30,6 +31,8 @@ import org.hl7.fhir.common.hapi.validation.support.InMemoryTerminologyServerVali import org.hl7.fhir.common.hapi.validation.support.UnknownCodeSystemWarningValidationSupport import org.hl7.fhir.common.hapi.validation.support.ValidationSupportChain import org.hl7.fhir.common.hapi.validation.validator.FhirInstanceValidator +import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.validation.ResourceValidationRequestHandler @Module @InstallIn(SingletonComponent::class) @@ -52,4 +55,13 @@ class FhirValidatorModule { instanceValidator.invalidateCaches() return fhirContext.newValidator().apply { registerValidatorModule(instanceValidator) } } + + @Provides + @Singleton + fun provideResourceValidationRequestHandler( + fhirValidatorProvider: Lazy, + dispatcherProvider: DispatcherProvider, + ): ResourceValidationRequestHandler { + return ResourceValidationRequestHandler(fhirValidatorProvider.get(), dispatcherProvider) + } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/AlertDialogue.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/AlertDialogue.kt index b329d1a5560..bee9d7febde 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/AlertDialogue.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/AlertDialogue.kt @@ -55,6 +55,8 @@ object AlertDialogue { @StringRes confirmButtonText: Int = R.string.questionnaire_alert_confirm_button_title, neutralButtonListener: ((d: DialogInterface) -> Unit)? = null, @StringRes neutralButtonText: Int = R.string.questionnaire_alert_neutral_button_title, + negativeButtonListener: ((d: DialogInterface) -> Unit)? = null, + @StringRes negativeButtonText: Int = R.string.questionnaire_alert_negative_button_title, cancellable: Boolean = false, options: Array? = null, ): AlertDialog { @@ -71,6 +73,9 @@ object AlertDialogue { confirmButtonListener?.let { setPositiveButton(confirmButtonText) { d, _ -> confirmButtonListener.invoke(d) } } + negativeButtonListener?.let { + setNegativeButton(negativeButtonText) { d, _ -> negativeButtonListener.invoke(d) } + } options?.run { setSingleChoiceItems(options.map { it.value }.toTypedArray(), -1, null) } } .show() @@ -172,6 +177,8 @@ object AlertDialogue { @StringRes confirmButtonText: Int, neutralButtonListener: ((d: DialogInterface) -> Unit), @StringRes neutralButtonText: Int, + negativeButtonListener: ((d: DialogInterface) -> Unit), + @StringRes negativeButtonText: Int, cancellable: Boolean = true, options: List? = null, ): AlertDialog { @@ -184,6 +191,8 @@ object AlertDialogue { confirmButtonText = confirmButtonText, neutralButtonListener = neutralButtonListener, neutralButtonText = neutralButtonText, + negativeButtonListener = negativeButtonListener, + negativeButtonText = negativeButtonText, cancellable = cancellable, options = options?.toTypedArray(), ) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/KnowledgeManagerUtil.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/KnowledgeManagerUtil.kt index e1cf11ad0f3..95697941d33 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/KnowledgeManagerUtil.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/KnowledgeManagerUtil.kt @@ -28,14 +28,26 @@ object KnowledgeManagerUtil { const val KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER = "km" private val fhirContext = FhirContext.forR4Cached() + /** + * Util method that creates a physical file and writes the Metadata FHIR resource content to it. + * Note the filepath provided is appended to the apps private directory as returned by + * Context.filesDir + * + * @param subFilePath the path of the file but within the apps private directory + * {Context.filesDir} + * @param metadataResource the actual FHIR Resource of type MetadataResource + * @param configService the configuration service + * @param context the application context + * @return File the file object after creating and writing + */ fun writeToFile( - filePath: String, + subFilePath: String, metadataResource: MetadataResource, configService: ConfigService, context: Context, ): File = context - .createFileInPrivateDirectory(filePath) + .createFileInPrivateDirectory(subFilePath) .also { it.parentFile?.mkdirs() } .apply { writeText( diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirValidatorExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirValidatorExtension.kt deleted file mode 100644 index 3a7c28f60f7..00000000000 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirValidatorExtension.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2021-2024 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.util.extension - -import ca.uhn.fhir.validation.FhirValidator -import ca.uhn.fhir.validation.ResultSeverityEnum -import ca.uhn.fhir.validation.ValidationResult -import kotlin.coroutines.coroutineContext -import kotlinx.coroutines.withContext -import org.hl7.fhir.r4.model.Resource -import org.smartregister.fhircore.engine.BuildConfig -import timber.log.Timber - -data class ResourceValidationResult( - val resource: Resource, - val validationResult: ValidationResult, -) { - val errorMessages - get() = buildString { - val messages = - validationResult.messages.filter { - it.severity.ordinal >= ResultSeverityEnum.WARNING.ordinal - } - - for (validationMsg in messages) { - appendLine( - "${validationMsg.message} - ${validationMsg.locationString} -- (${validationMsg.severity})", - ) - } - } -} - -data class FhirValidatorResultsWrapper(val results: List = emptyList()) { - val errorMessages = results.map { it.errorMessages } -} - -suspend fun FhirValidator.checkResourceValid( - vararg resource: Resource, - isDebug: Boolean = BuildConfig.DEBUG, -): FhirValidatorResultsWrapper { - if (!isDebug) return FhirValidatorResultsWrapper() - - return withContext(coroutineContext) { - FhirValidatorResultsWrapper( - results = - resource.map { - val result = this@checkResourceValid.validateWithResult(it) - ResourceValidationResult(it, result) - }, - ) - } -} - -fun FhirValidatorResultsWrapper.logErrorMessages() { - results.forEach { - if (it.errorMessages.isNotBlank()) { - Timber.tag("$TAG (${it.resource.referenceValue()})").e(it.errorMessages) - } - } -} - -private const val TAG = "FhirValidator" diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt index cd2ffafc4c9..bd252f6b0ca 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt @@ -26,6 +26,7 @@ import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseStatus import org.hl7.fhir.r4.model.StringType import org.smartregister.fhircore.engine.configuration.LinkIdType import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig @@ -292,3 +293,19 @@ suspend fun Questionnaire.prepopulateUniqueIdAssignment( } } } + +/** + * Determines the [QuestionnaireResponse.Status] depending on the [saveDraft] and [isEditable] + * values contained in the [QuestionnaireConfig] + * + * returns [COMPLETED] when [isEditable] is [true] returns [INPROGRESS] when [saveDraft] is [true] + */ +fun QuestionnaireConfig.questionnaireResponseStatus(): String? { + return if (this.isEditable()) { + QuestionnaireResponseStatus.COMPLETED.toCode() + } else if (this.saveDraft) { + QuestionnaireResponseStatus.INPROGRESS.toCode() + } else { + null + } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/validation/ResourceValidationRequest.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/validation/ResourceValidationRequest.kt new file mode 100644 index 00000000000..ba398a5bf87 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/validation/ResourceValidationRequest.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2021-2024 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.util.validation + +import ca.uhn.fhir.validation.FhirValidator +import ca.uhn.fhir.validation.ResultSeverityEnum +import ca.uhn.fhir.validation.ValidationResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.Resource +import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.extension.referenceValue +import timber.log.Timber + +data class ResourceValidationRequest(val resources: List) { + constructor(vararg resource: Resource) : this(resource.toList()) +} + +class ResourceValidationRequestHandler( + private val fhirValidator: FhirValidator, + private val dispatcherProvider: DispatcherProvider, +) { + fun handleResourceValidationRequest(request: ResourceValidationRequest) { + CoroutineScope(dispatcherProvider.io()).launch { + val resources = request.resources + fhirValidator.checkResources(resources).logErrorMessages() + } + } +} + +internal data class ResourceValidationResult( + val resource: Resource, + val validationResult: ValidationResult, +) { + val errorMessages + get() = buildString { + val messages = + validationResult.messages.filter { + it.severity.ordinal >= ResultSeverityEnum.WARNING.ordinal + } + if (messages.isNotEmpty()) { + appendLine(resource.referenceValue()) + } + for (validationMsg in messages) { + appendLine( + "${validationMsg.locationString} - ${validationMsg.message} -- (${validationMsg.severity})", + ) + } + } +} + +internal class FhirValidatorResultsWrapper( + val results: List = emptyList(), +) { + val errorMessages = results.map { it.errorMessages } + + fun logErrorMessages() { + results.forEach { + if (it.errorMessages.isNotBlank()) { + Timber.tag(TAG).e(it.errorMessages) + } + } + } + + companion object { + private const val TAG = "FhirValidatorResult" + } +} + +internal fun FhirValidator.checkResources( + resources: List, +): FhirValidatorResultsWrapper { + return FhirValidatorResultsWrapper( + results = + resources.map { + val result = this@checkResources.validateWithResult(it) + ResourceValidationResult(it, result) + }, + ) +} diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index cb1930e2734..7bc1d98de17 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -65,11 +65,12 @@ Error encountered cannot save form Processing data. Please wait Are you sure you want to go back? - Are you sure you want to discard the answers? - Discard changes - Discard - Save partial draft + If you leave without saving, all your changes will not be saved + You have unsaved changes + Discard changes + Save as draft Cancel + Discard Changes Yes Given details have validation errors. Resolve errors and submit again Validation Failed @@ -198,4 +199,6 @@ There\'s some un-synced data Supervisor contact missing or the provided phone number is invalid APPLY FILTER + Save draft changes + Do you want to save draft changes? diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt index 31e2665ea13..7ea15619ccb 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt @@ -1028,7 +1028,7 @@ class ConfigurationRegistryTest : RobolectricTest() { configService = configService, metadataResource = resource, context = context, - filePath = + subFilePath = "${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/${resource.resourceType}/${resource.idElement.idPart}.json", ) assertNotNull(resultFile) diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/base/AlertDialogueTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/base/AlertDialogueTest.kt index 36296d09c91..401ef8cc2cc 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/base/AlertDialogueTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/base/AlertDialogueTest.kt @@ -151,6 +151,8 @@ class AlertDialogueTest : ActivityRobolectricTest() { confirmButtonText = R.string.questionnaire_alert_back_pressed_save_draft_button_title, neutralButtonListener = {}, neutralButtonText = R.string.questionnaire_alert_back_pressed_button_title, + negativeButtonListener = {}, + negativeButtonText = R.string.questionnaire_alert_negative_button_title, ) val dialog = shadowOf(ShadowAlertDialog.getLatestAlertDialog()) diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/KnowledgeManagerUtilTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/KnowledgeManagerUtilTest.kt new file mode 100644 index 00000000000..2cd1c0dbf78 --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/KnowledgeManagerUtilTest.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2021-2024 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.util + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import ca.uhn.fhir.context.FhirContext +import java.io.File +import org.hl7.fhir.r4.model.StructureMap +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.smartregister.fhircore.engine.app.AppConfigService +import org.smartregister.fhircore.engine.robolectric.RobolectricTest + +class KnowledgeManagerUtilTest : RobolectricTest() { + + private lateinit var configService: AppConfigService + private val context = ApplicationProvider.getApplicationContext()!! + + @Before + fun setUp() { + configService = AppConfigService(context) + } + + @Test + fun testWriteToFile() { + val structureMap = StructureMap().apply { id = "structure-map-id" } + + val filePath = + "${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/StructureMap/structure-map-id.json" + val absoluteFilePath = "${context.filesDir}/$filePath" + + val file = File(absoluteFilePath) + Assert.assertFalse(file.exists()) + + KnowledgeManagerUtil.writeToFile(filePath, structureMap, configService, context) + + Assert.assertTrue(file.exists()) + + val savedStructureMap = + FhirContext.forR4Cached().newJsonParser().parseResource(file.readText()) as StructureMap + Assert.assertNotNull(savedStructureMap.url) + Assert.assertEquals( + "http://fake.base.url.com/StructureMap/structure-map-id", + savedStructureMap.url, + ) + } + + @After + fun tearDown() { + val testFile = + File( + "${context.filesDir}/${KnowledgeManagerUtil.KNOWLEDGE_MANAGER_ASSETS_SUBFOLDER}/StructureMap/structure-map-id.json", + ) + if (testFile.exists()) { + testFile.delete() + } + } +} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtensionTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtensionTest.kt index 5e55a48403d..be5cffa2609 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtensionTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtensionTest.kt @@ -471,4 +471,21 @@ class QuestionnaireExtensionTest : RobolectricTest() { barCodeItemValue?.primitiveValue(), ) } + + @Test + fun testQuestionnaireResponseStatusReturnsCompletedWhenIsEditableIsTrue() { + val questionnaireConfig = + QuestionnaireConfig(id = "patient-reg-config", type = QuestionnaireType.EDIT.name) + Assert.assertEquals("completed", questionnaireConfig.questionnaireResponseStatus()) + } + + fun testQuestionnaireResponseStatusReturnsInProgressWhenSaveDraftIsTrue() { + val questionnaireConfig = QuestionnaireConfig(id = "patient-reg-config", saveDraft = true) + Assert.assertEquals("in-progress", questionnaireConfig.questionnaireResponseStatus()) + } + + fun testQuestionnaireResponseStatusReturnsNullWhenBothSaveDraftAndIsEditableAreFalse() { + val questionnaireConfig = QuestionnaireConfig(id = "patient-reg-config", saveDraft = true) + Assert.assertEquals("in-progress", questionnaireConfig.questionnaireResponseStatus()) + } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/FhirValidatorExtensionTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/validation/ResourceValidationRequestTest.kt similarity index 65% rename from android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/FhirValidatorExtensionTest.kt rename to android/engine/src/test/java/org/smartregister/fhircore/engine/util/validation/ResourceValidationRequestTest.kt index f2dff7fe0db..0e3d8d2a8f2 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/FhirValidatorExtensionTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/validation/ResourceValidationRequestTest.kt @@ -14,16 +14,16 @@ * limitations under the License. */ -package org.smartregister.fhircore.engine.util.extension +package org.smartregister.fhircore.engine.util.validation import ca.uhn.fhir.validation.FhirValidator import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import io.mockk.spyk +import io.mockk.mockkObject +import io.mockk.unmockkObject import io.mockk.verify import javax.inject.Inject import kotlinx.coroutines.test.runTest -import org.hl7.fhir.instance.model.api.IBaseResource import org.hl7.fhir.r4.model.CarePlan import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Reference @@ -32,33 +32,51 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import org.smartregister.fhircore.engine.robolectric.RobolectricTest +import timber.log.Timber @HiltAndroidTest -class FhirValidatorExtensionTest : RobolectricTest() { - +class ResourceValidationRequestTest : RobolectricTest() { @get:Rule var hiltRule = HiltAndroidRule(this) @Inject lateinit var validator: FhirValidator + @Inject lateinit var resourceValidationRequestHandler: ResourceValidationRequestHandler + @Before fun setUp() { hiltRule.inject() } @Test - fun testCheckResourceValidRunsNoValidationWhenBuildTypeIsNotDebug() = runTest { - val basicResource = CarePlan() - val fhirValidatorSpy = spyk(validator) - val resultsWrapper = fhirValidatorSpy.checkResourceValid(basicResource, isDebug = false) - Assert.assertTrue(resultsWrapper.results.isEmpty()) - verify(exactly = 0) { fhirValidatorSpy.validateWithResult(any()) } - verify(exactly = 0) { fhirValidatorSpy.validateWithResult(any()) } + fun testHandleResourceValidationRequestValidatesInvalidResourceLoggingErrors() = runTest { + mockkObject(Timber) + val resource = + CarePlan().apply { + id = "test-careplan" + status = CarePlan.CarePlanStatus.ACTIVE + intent = CarePlan.CarePlanIntent.PLAN + subject = Reference("f4bd3e29-f0f8-464e-97af-923b83664ccc") + } + val validationRequest = ResourceValidationRequest(resource) + resourceValidationRequestHandler.handleResourceValidationRequest(validationRequest) + verify { + Timber.e( + withArg { + Assert.assertTrue( + it.contains( + "CarePlan.subject - The syntax of the reference 'f4bd3e29-f0f8-464e-97af-923b83664ccc' looks incorrect, and it should be checked -- (WARNING)", + ), + ) + }, + ) + } + unmockkObject(Timber) } @Test fun testCheckResourceValidValidatesResourceStructureWhenCarePlanResourceInvalid() = runTest { val basicCarePlan = CarePlan() - val resultsWrapper = validator.checkResourceValid(basicCarePlan) + val resultsWrapper = validator.checkResources(listOf(basicCarePlan)) Assert.assertTrue( resultsWrapper.errorMessages.any { it.contains( @@ -85,13 +103,13 @@ class FhirValidatorExtensionTest : RobolectricTest() { intent = CarePlan.CarePlanIntent.PLAN subject = Reference("Task/unknown") } - val resultsWrapper = validator.checkResourceValid(carePlan) + val resultsWrapper = validator.checkResources(listOf(carePlan)) Assert.assertEquals(1, resultsWrapper.errorMessages.size) Assert.assertTrue( resultsWrapper.errorMessages .first() .contains( - "The type 'Task' implied by the reference URL Task/unknown is not a valid Target for this element (must be one of [Group, Patient]) - CarePlan.subject", + "CarePlan.subject - The type 'Task' implied by the reference URL Task/unknown is not a valid Target for this element (must be one of [Group, Patient])", ignoreCase = true, ), ) @@ -105,13 +123,13 @@ class FhirValidatorExtensionTest : RobolectricTest() { intent = CarePlan.CarePlanIntent.PLAN subject = Reference("unknown") } - val resultsWrapper = validator.checkResourceValid(carePlan) + val resultsWrapper = validator.checkResources(listOf(carePlan)) Assert.assertEquals(1, resultsWrapper.errorMessages.size) Assert.assertTrue( resultsWrapper.errorMessages .first() .contains( - "The syntax of the reference 'unknown' looks incorrect, and it should be checked - CarePlan.subject", + "CarePlan.subject - The syntax of the reference 'unknown' looks incorrect, and it should be checked", ignoreCase = true, ), ) @@ -126,7 +144,7 @@ class FhirValidatorExtensionTest : RobolectricTest() { intent = CarePlan.CarePlanIntent.PLAN subject = Reference(patient) } - val resultsWrapper = validator.checkResourceValid(carePlan) + val resultsWrapper = validator.checkResources(listOf(carePlan)) Assert.assertEquals(1, resultsWrapper.errorMessages.size) Assert.assertTrue(resultsWrapper.errorMessages.first().isBlank()) } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 7679347aecc..998311d4bd9 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -6,7 +6,7 @@ androidx-camera = "1.4.0-rc02" androidx-paging = "3.3.2" androidx-test= "1.6.2" appcompat = "1.7.0" -benchmark-junit = "1.3.1" +benchmark-junit = "1.3.3" cardview = "1.0.0" common-utils = "1.0.0-SNAPSHOT" compose-ui = "1.6.8" @@ -20,14 +20,15 @@ coverallsGradlePlugin = "2.12.2" cqfFhirCr = "3.0.0-PRE9" dagger-hilt = "2.51" datastore = "1.1.1" -desugar-jdk-libs = "2.1.2" +desugar-jdk-libs = "2.1.3" dokkaBase = "1.9.20" easyRulesCore = "4.1.1-SNAPSHOT" espresso-core = "3.6.1" fhir-sdk-contrib-barcode = "0.1.0-beta3-preview7-rc1-SNAPSHOT" fhir-sdk-contrib-locationwidget = "0.1.0-alpha01-preview2-rc1-SNAPSHOT" -fhir-sdk-data-capture = "1.2.0-preview3-SNAPSHOT" -fhir-sdk-engine = "1.0.0-preview15-DBOPT-SNAPSHOT" +#fhir-sdk-engine = "1.0.0-preview15-DBOPT-SNAPSHOT" @martin please remove this if you don't need it. +fhir-sdk-data-capture = "1.2.0-preview4-SNAPSHOT" +fhir-sdk-engine = "1.0.0-preview16-SNAPSHOT" fhir-sdk-knowledge = "0.1.0-alpha03-preview5-rc1-SNAPSHOT" fhir-sdk-workflow = "0.1.0-alpha04-preview10-rc1-SNAPSHOT" fragment-ktx = "1.8.3" @@ -62,7 +63,7 @@ msg-simple = "1.2" navigation = "2.7.7" okhttp = "4.12.0" okhttp-logging-interceptor = "4.12.0" -orchestrator = "1.5.0" +orchestrator = "1.5.1" owasp = "8.2.1" p2p-lib = "0.6.11-SNAPSHOT" playServicesLocation = "21.3.0" @@ -82,7 +83,7 @@ timber = "5.0.1" uiautomator = "2.3.0" work = "2.9.1" xercesImpl = "2.12.2" -androidFragmentCompose = "1.8.4" +androidFragmentCompose = "1.8.5" [libraries] accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanist" } diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/Faker.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/Faker.kt index 01081c3ca8c..aa34696bade 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/Faker.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/Faker.kt @@ -18,10 +18,10 @@ package org.smartregister.fhircore.quest.integration import androidx.test.core.app.ApplicationProvider import androidx.test.platform.app.InstrumentationRegistry -import com.google.android.fhir.CrudFhirEngine import com.google.android.fhir.FhirEngine import com.google.android.fhir.LocalChange import com.google.android.fhir.SearchResult +import com.google.android.fhir.db.LocalChangeResourceReference import com.google.android.fhir.search.Search import com.google.android.fhir.sync.ConflictResolver import com.google.android.fhir.sync.upload.SyncUploadProgress @@ -104,14 +104,17 @@ object Faker { override suspend fun syncUpload( uploadStrategy: UploadStrategy, - upload: suspend (List) -> Flow, + upload: + suspend (List, List) -> Flow< + UploadRequestResult, + >, ): Flow { return flowOf() } override suspend fun update(vararg resource: Resource) {} - override suspend fun withTransaction(block: suspend CrudFhirEngine.() -> Unit) { + override suspend fun withTransaction(block: suspend FhirEngine.() -> Unit) { TODO("Not yet implemented") } } diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/main/components/TopScreenSectionTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/main/components/TopScreenSectionTest.kt index 11316b4c6ff..b15b59abf15 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/main/components/TopScreenSectionTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/main/components/TopScreenSectionTest.kt @@ -28,6 +28,7 @@ import org.junit.Rule import org.junit.Test import org.smartregister.fhircore.quest.ui.main.components.LEADING_ICON_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.OUTLINED_BOX_TEST_TAG +import org.smartregister.fhircore.quest.ui.main.components.SEARCH_FIELD_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.TITLE_ROW_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.TOP_ROW_ICON_TEST_TAG import org.smartregister.fhircore.quest.ui.main.components.TOP_ROW_TEXT_TEST_TAG @@ -106,6 +107,50 @@ class TopScreenSectionTest { .assertIsDisplayed() } + @Test + fun testTopScreenSectionRendersPlaceholderCorrectly() { + composeTestRule.setContent { + TopScreenSection( + title = "All Clients", + searchQuery = SearchQuery(""), + onSearchTextChanged = listener, + navController = TestNavHostController(LocalContext.current), + isSearchBarVisible = true, + placeholderColor = null, + searchPlaceholder = "Custom placeholder", + onClick = {}, + decodeImage = null, + ) + } + + composeTestRule + .onNodeWithTag(SEARCH_FIELD_TEST_TAG, useUnmergedTree = true) + .assertExists() + .assertIsDisplayed() + } + + @Test + fun testTopScreenSectionRendersPlaceholderColorCorrectly() { + composeTestRule.setContent { + TopScreenSection( + title = "All Clients", + searchQuery = SearchQuery(""), + onSearchTextChanged = listener, + navController = TestNavHostController(LocalContext.current), + isSearchBarVisible = true, + placeholderColor = "#FF0000", + searchPlaceholder = "Custom placeholder", + onClick = {}, + decodeImage = null, + ) + } + + composeTestRule + .onNodeWithTag(SEARCH_FIELD_TEST_TAG, useUnmergedTree = true) + .assertExists() + .assertIsDisplayed() + } + @Test fun testThatTrailingIconClickCallsTheListener() { var clicked = false 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 2cf5fd089a7..af2bd8542c7 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 @@ -55,6 +55,7 @@ import org.smartregister.fhircore.engine.configuration.navigation.NavigationConf import org.smartregister.fhircore.engine.configuration.navigation.NavigationMenuConfig import org.smartregister.fhircore.engine.configuration.register.NoResultsConfig import org.smartregister.fhircore.engine.configuration.register.RegisterConfiguration +import org.smartregister.fhircore.engine.configuration.register.RegisterContentConfig import org.smartregister.fhircore.engine.configuration.workflow.ActionTrigger import org.smartregister.fhircore.engine.domain.model.ActionConfig import org.smartregister.fhircore.engine.domain.model.Language @@ -67,6 +68,7 @@ import org.smartregister.fhircore.quest.ui.register.NO_REGISTER_VIEW_COLUMN_TEST import org.smartregister.fhircore.quest.ui.register.NoRegisterDataView import org.smartregister.fhircore.quest.ui.register.REGISTER_CARD_TEST_TAG import org.smartregister.fhircore.quest.ui.register.RegisterScreen +import org.smartregister.fhircore.quest.ui.register.RegisterUiCountState import org.smartregister.fhircore.quest.ui.register.RegisterUiState import org.smartregister.fhircore.quest.ui.register.TOP_REGISTER_SCREEN_TEST_TAG import org.smartregister.fhircore.quest.ui.shared.components.SYNC_PROGRESS_INDICATOR_TEST_TAG @@ -126,9 +128,6 @@ class RegisterScreenTest { registerConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), registerId = "register101", - totalRecordsCount = 1, - filteredRecordsCount = 0, - pagesCount = 0, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), params = emptyList(), @@ -146,6 +145,12 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), onAppMainEvent = {}, searchQuery = searchText, currentPage = currentPage, @@ -159,18 +164,80 @@ class RegisterScreenTest { } @Test - fun testRegisterCardListIsRendered() { + fun testRegisterScreenWithPlaceholderColor() { val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() val registerUiState = RegisterUiState( screenTitle = "Register101", isFirstTimeSync = false, registerConfiguration = - configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), + configurationRegistry + .retrieveConfiguration(ConfigType.Register, "householdRegister") + .copy( + searchBar = + RegisterContentConfig( + visible = true, + display = "Search", + placeholderColor = "#FF0000", + ), + ), registerId = "register101", totalRecordsCount = 1, filteredRecordsCount = 0, - pagesCount = 1, + pagesCount = 0, + progressPercentage = flowOf(0), + isSyncUpload = flowOf(false), + params = emptyList(), + ) + val searchText = mutableStateOf(SearchQuery.emptyText) + val currentPage = mutableStateOf(0) + + composeTestRule.setContent { + val data = listOf(ResourceData("1", ResourceType.Patient, emptyMap())) + val pagingItems = flowOf(PagingData.from(data)).collectAsLazyPagingItems() + + RegisterScreen( + modifier = Modifier, + openDrawer = {}, + onEvent = {}, + registerUiState = registerUiState, + onAppMainEvent = {}, + searchQuery = searchText, + currentPage = currentPage, + pagingItems = pagingItems, + navController = rememberNavController(), + decodeImage = null, + ) + } + + // Verify that all nodes with the TOP_REGISTER_SCREEN_TEST_TAG exist + composeTestRule + .onAllNodesWithTag(TOP_REGISTER_SCREEN_TEST_TAG, useUnmergedTree = true) + .assertCountEquals(7) + + // Verify that the search text exists with correct placeholder + composeTestRule + .onNodeWithText("Search", useUnmergedTree = true) + .assertExists() + .assertIsDisplayed() + + // Verify that the screen title is displayed + composeTestRule + .onNodeWithText("Register101", useUnmergedTree = true) + .assertExists() + .assertIsDisplayed() + } + + @Test + fun testRegisterCardListIsRendered() { + val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + val registerUiState = + RegisterUiState( + screenTitle = "Register101", + isFirstTimeSync = false, + registerConfiguration = + configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), + registerId = "register101", progressPercentage = flowOf(0), isSyncUpload = flowOf(false), params = emptyList(), @@ -188,6 +255,12 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 1, + ), onAppMainEvent = {}, searchQuery = searchText, currentPage = currentPage, @@ -213,13 +286,17 @@ class RegisterScreenTest { registerConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), registerId = "register101", - totalRecordsCount = 1, - filteredRecordsCount = 0, - pagesCount = 1, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), params = emptyList(), ) + + val registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 1, + ) val searchText = mutableStateOf(SearchQuery.emptyText) val currentPage = mutableStateOf(0) @@ -233,6 +310,7 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = registerUiCountState, onAppMainEvent = {}, searchQuery = searchText, currentPage = currentPage, @@ -258,9 +336,6 @@ class RegisterScreenTest { registerConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Register, "childRegister"), registerId = "register101", - totalRecordsCount = 0, - filteredRecordsCount = 0, - pagesCount = 1, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), params = emptyList(), @@ -283,6 +358,12 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 0, + filteredRecordsCount = 0, + pagesCount = 1, + ), onAppMainEvent = {}, searchQuery = searchText, currentPage = currentPage, @@ -304,9 +385,6 @@ class RegisterScreenTest { registerConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), registerId = "register101", - totalRecordsCount = 1, - filteredRecordsCount = 0, - pagesCount = 0, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), params = emptyList(), @@ -324,6 +402,12 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), onAppMainEvent = {}, searchQuery = searchText, currentPage = currentPage, @@ -351,9 +435,6 @@ class RegisterScreenTest { listOf(ActionConfig(trigger = ActionTrigger.ON_SEARCH_SINGLE_RESULT)), ), registerId = "register101", - totalRecordsCount = 1, - filteredRecordsCount = 0, - pagesCount = 0, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), params = emptyList(), @@ -371,6 +452,12 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), onAppMainEvent = {}, searchQuery = searchText, currentPage = currentPage, @@ -429,9 +516,6 @@ class RegisterScreenTest { registerConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), registerId = "register101", - totalRecordsCount = 1, - filteredRecordsCount = 0, - pagesCount = 0, progressPercentage = flowOf(50), isSyncUpload = flowOf(true), currentSyncJobStatus = @@ -455,6 +539,12 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), appDrawerUIState = AppDrawerUIState( currentSyncJobStatus = @@ -489,9 +579,6 @@ class RegisterScreenTest { registerConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), registerId = "register101", - totalRecordsCount = 1, - filteredRecordsCount = 0, - pagesCount = 0, progressPercentage = flowOf(100), isSyncUpload = flowOf(false), currentSyncJobStatus = flowOf(CurrentSyncJobStatus.Succeeded(OffsetDateTime.now())), @@ -509,6 +596,12 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), appDrawerUIState = AppDrawerUIState( currentSyncJobStatus = CurrentSyncJobStatus.Succeeded(OffsetDateTime.now()), @@ -541,9 +634,6 @@ class RegisterScreenTest { registerConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), registerId = "register101", - totalRecordsCount = 1, - filteredRecordsCount = 0, - pagesCount = 0, progressPercentage = flowOf(100), isSyncUpload = flowOf(false), currentSyncJobStatus = flowOf(CurrentSyncJobStatus.Succeeded(OffsetDateTime.now())), @@ -561,6 +651,12 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), appDrawerUIState = AppDrawerUIState( currentSyncJobStatus = CurrentSyncJobStatus.Failed(OffsetDateTime.now()), diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/components/RegisterCardListTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/components/RegisterCardListTest.kt index 8d3967f60a9..65531bc9548 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/components/RegisterCardListTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/register/components/RegisterCardListTest.kt @@ -37,6 +37,7 @@ import org.junit.Test import org.smartregister.fhircore.engine.configuration.register.RegisterCardConfig import org.smartregister.fhircore.engine.configuration.view.CompoundTextProperties import org.smartregister.fhircore.engine.domain.model.ResourceData +import org.smartregister.fhircore.quest.ui.register.RegisterUiCountState import org.smartregister.fhircore.quest.ui.register.RegisterUiState import org.smartregister.fhircore.quest.ui.register.components.REGISTER_CARD_LIST_TEST_TAG import org.smartregister.fhircore.quest.ui.register.components.RegisterCardList @@ -58,6 +59,7 @@ class RegisterCardListTest { lazyListState = rememberLazyListState(), onEvent = {}, registerUiState = RegisterUiState(), + registerUiCountState = RegisterUiCountState(), currentPage = mutableStateOf(1), onSearchByQrSingleResultAction = {}, decodeImage = null, @@ -84,6 +86,7 @@ class RegisterCardListTest { lazyListState = rememberLazyListState(), onEvent = {}, registerUiState = RegisterUiState(), + registerUiCountState = RegisterUiCountState(), currentPage = mutableStateOf(1), onSearchByQrSingleResultAction = {}, decodeImage = null, @@ -116,6 +119,7 @@ class RegisterCardListTest { lazyListState = rememberLazyListState(), onEvent = {}, registerUiState = RegisterUiState(), + registerUiCountState = RegisterUiCountState(), currentPage = mutableStateOf(1), showPagination = true, onSearchByQrSingleResultAction = {}, @@ -144,6 +148,7 @@ class RegisterCardListTest { lazyListState = rememberLazyListState(), onEvent = {}, registerUiState = RegisterUiState(), + registerUiCountState = RegisterUiCountState(), currentPage = mutableStateOf(1), onSearchByQrSingleResultAction = {}, decodeImage = null, diff --git a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/CompoundTextTest.kt b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/CompoundTextTest.kt index 1cf8311dfdc..9ca6820e8d5 100644 --- a/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/CompoundTextTest.kt +++ b/android/quest/src/androidTest/java/org/smartregister/fhircore/quest/integration/ui/shared/components/CompoundTextTest.kt @@ -45,6 +45,8 @@ class CompoundTextTest { primaryTextColor = "#000000", primaryTextFontWeight = TextFontWeight.SEMI_BOLD, padding = 16, + primaryTextBackgroundColor = "#FFA500", + textInnerPadding = 4, ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap(), emptyMap()), navController = TestNavHostController(LocalContext.current), @@ -67,6 +69,7 @@ class CompoundTextTest { separator = ".", secondaryTextBackgroundColor = "#FFA500", fontSize = 18.0f, + textInnerPadding = 4, ), resourceData = ResourceData("id", ResourceType.Patient, emptyMap(), emptyMap()), navController = TestNavHostController(LocalContext.current), 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 199dce84122..29c14d7acc6 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 @@ -114,6 +114,7 @@ fun TopScreenSection( showSearchByQrCode: Boolean = false, filteredRecordsCount: Long? = null, searchPlaceholder: String? = null, + placeholderColor: String? = null, toolBarHomeNavigation: ToolBarHomeNavigation = ToolBarHomeNavigation.OPEN_DRAWER, onSearchTextChanged: (SearchQuery, Boolean) -> Unit = { _, _ -> }, performSearchOnValueChanged: Boolean = true, @@ -206,7 +207,7 @@ fun TopScreenSection( singleLine = true, placeholder = { Text( - color = GreyTextColor, + color = placeholderColor?.parseColor() ?: GreyTextColor, text = searchPlaceholder ?: stringResource(R.string.search_hint), modifier = modifier.testTag(SEARCH_FIELD_TEST_TAG), ) 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 6595e8bba4f..4ee08404a19 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 @@ -217,59 +217,53 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { } private fun renderQuestionnaire() { + if (supportFragmentManager.findFragmentByTag(QUESTIONNAIRE_FRAGMENT_TAG) != null) return + lifecycleScope.launch { - var questionnaireFragment: QuestionnaireFragment? = null - if (supportFragmentManager.findFragmentByTag(QUESTIONNAIRE_FRAGMENT_TAG) == null) { - viewModel.setProgressState(QuestionnaireProgressState.QuestionnaireLaunch(true)) - with(viewBinding) { - questionnaireToolbar.apply { - setNavigationIcon(R.drawable.ic_cancel) - setNavigationOnClickListener { handleBackPress() } - } - questionnaireTitle.apply { text = questionnaireConfig.title } - clearAll.apply { - visibility = if (questionnaireConfig.showClearAll) View.VISIBLE else View.GONE - setOnClickListener { questionnaireFragment?.clearAllAnswers() } - } - } + viewModel.setProgressState(QuestionnaireProgressState.QuestionnaireLaunch(true)) - questionnaire = viewModel.retrieveQuestionnaire(questionnaireConfig) + viewBinding.questionnaireToolbar.setNavigationIcon(R.drawable.ic_cancel) + viewBinding.questionnaireToolbar.setNavigationOnClickListener { handleBackPress() } + viewBinding.questionnaireTitle.text = questionnaireConfig.title + viewBinding.clearAll.visibility = + if (questionnaireConfig.showClearAll) View.VISIBLE else View.GONE - try { - val questionnaireFragmentBuilder = - buildQuestionnaireFragment( - questionnaire = questionnaire!!, - questionnaireConfig = questionnaireConfig, - ) + questionnaire = viewModel.retrieveQuestionnaire(questionnaireConfig) - questionnaireFragment = questionnaireFragmentBuilder.build() - supportFragmentManager.commit { - setReorderingAllowed(true) - add(R.id.container, questionnaireFragment, QUESTIONNAIRE_FRAGMENT_TAG) - } + if (questionnaire == null) { + showToast(getString(R.string.questionnaire_not_found)) + finish() + return@launch + } + if (questionnaire!!.subjectType.isNullOrEmpty()) { + val subjectRequiredMessage = getString(R.string.missing_subject_type) + showToast(subjectRequiredMessage) + Timber.e(subjectRequiredMessage) + finish() + return@launch + } - registerFragmentResultListener() - } catch (nullPointerException: NullPointerException) { - showToast(getString(R.string.questionnaire_not_found)) - finish() - } finally { - viewModel.setProgressState(QuestionnaireProgressState.QuestionnaireLaunch(false)) - } + val questionnaireFragment = + getQuestionnaireFragmentBuilder( + questionnaire = questionnaire!!, + questionnaireConfig = questionnaireConfig, + ) + .build() + viewBinding.clearAll.setOnClickListener { questionnaireFragment.clearAllAnswers() } + supportFragmentManager.commit { + setReorderingAllowed(true) + add(R.id.container, questionnaireFragment, QUESTIONNAIRE_FRAGMENT_TAG) } + registerFragmentResultListener() + + viewModel.setProgressState(QuestionnaireProgressState.QuestionnaireLaunch(false)) } } - private suspend fun buildQuestionnaireFragment( + private suspend fun getQuestionnaireFragmentBuilder( questionnaire: Questionnaire, questionnaireConfig: QuestionnaireConfig, ): QuestionnaireFragment.Builder { - if (questionnaire.subjectType.isNullOrEmpty()) { - val subjectRequiredMessage = getString(R.string.missing_subject_type) - showToast(subjectRequiredMessage) - Timber.e(subjectRequiredMessage) - finish() - } - val (questionnaireResponse, launchContextResources) = viewModel.populateQuestionnaire(questionnaire, this.questionnaireConfig, actionParameters) @@ -367,15 +361,19 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { lifecycleScope.launch { retrieveQuestionnaireResponse()?.let { questionnaireResponse -> viewModel.saveDraftQuestionnaire(questionnaireResponse) + finish() } } }, confirmButtonText = org.smartregister.fhircore.engine.R.string .questionnaire_alert_back_pressed_save_draft_button_title, - neutralButtonListener = { finish() }, + neutralButtonListener = {}, neutralButtonText = - org.smartregister.fhircore.engine.R.string.questionnaire_alert_back_pressed_button_title, + org.smartregister.fhircore.engine.R.string.questionnaire_alert_neutral_button_title, + negativeButtonListener = { finish() }, + negativeButtonText = + org.smartregister.fhircore.engine.R.string.questionnaire_alert_negative_button_title, ) } else { AlertDialogue.showConfirmAlert( 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 ca814752f46..55b4a5b59d1 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 @@ -23,7 +23,6 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.validation.FhirValidator import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.datacapture.mapping.ResourceMapper import com.google.android.fhir.datacapture.mapping.StructureMapExtractionContext @@ -34,7 +33,12 @@ import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.search.Search import com.google.android.fhir.search.filter.TokenParamFilterCriterion import com.google.android.fhir.workflow.FhirOperator +import dagger.Lazy import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.Date +import java.util.LinkedList +import java.util.UUID +import javax.inject.Inject import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -78,7 +82,6 @@ import org.smartregister.fhircore.engine.util.extension.appendPractitionerInfo import org.smartregister.fhircore.engine.util.extension.appendRelatedEntityLocation import org.smartregister.fhircore.engine.util.extension.asReference import org.smartregister.fhircore.engine.util.extension.batchedSearch -import org.smartregister.fhircore.engine.util.extension.checkResourceValid import org.smartregister.fhircore.engine.util.extension.clearText import org.smartregister.fhircore.engine.util.extension.cqfLibraryUrls import org.smartregister.fhircore.engine.util.extension.extractByStructureMap @@ -87,1138 +90,1166 @@ import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.find import org.smartregister.fhircore.engine.util.extension.generateMissingId import org.smartregister.fhircore.engine.util.extension.isIn -import org.smartregister.fhircore.engine.util.extension.logErrorMessages import org.smartregister.fhircore.engine.util.extension.packRepeatedGroups import org.smartregister.fhircore.engine.util.extension.prepopulateWithComputedConfigValues +import org.smartregister.fhircore.engine.util.extension.questionnaireResponseStatus import org.smartregister.fhircore.engine.util.extension.showToast import org.smartregister.fhircore.engine.util.extension.updateLastUpdated import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor import org.smartregister.fhircore.engine.util.helper.TransformSupportServices +import org.smartregister.fhircore.engine.util.validation.ResourceValidationRequest +import org.smartregister.fhircore.engine.util.validation.ResourceValidationRequestHandler import org.smartregister.fhircore.quest.R import timber.log.Timber -import java.util.Date -import java.util.LinkedList -import java.util.UUID -import javax.inject.Inject -import javax.inject.Provider @HiltViewModel class QuestionnaireViewModel @Inject constructor( - val defaultRepository: DefaultRepository, - val dispatcherProvider: DispatcherProvider, - val fhirCarePlanGenerator: FhirCarePlanGenerator, - val resourceDataRulesExecutor: ResourceDataRulesExecutor, - val transformSupportServices: TransformSupportServices, - val sharedPreferencesHelper: SharedPreferencesHelper, - val fhirOperator: FhirOperator, - val fhirValidatorProvider: Provider, - val fhirPathDataExtractor: FhirPathDataExtractor, - val configurationRegistry: ConfigurationRegistry, + val defaultRepository: DefaultRepository, + val dispatcherProvider: DispatcherProvider, + val fhirCarePlanGenerator: FhirCarePlanGenerator, + val resourceDataRulesExecutor: ResourceDataRulesExecutor, + val transformSupportServices: TransformSupportServices, + val sharedPreferencesHelper: SharedPreferencesHelper, + val fhirOperator: FhirOperator, + val fhirValidatorRequestHandlerProvider: Lazy, + val fhirPathDataExtractor: FhirPathDataExtractor, + val configurationRegistry: ConfigurationRegistry, ) : ViewModel() { - private val authenticatedOrganizationIds by lazy { - sharedPreferencesHelper.read>(ResourceType.Organization.name) - } - - private val practitionerId: String? by lazy { - sharedPreferencesHelper - .read(SharedPreferenceKey.PRACTITIONER_ID.name, null) - ?.extractLogicalIdUuid() - } - - private val _questionnaireProgressStateLiveData = MutableLiveData() - val questionnaireProgressStateLiveData: LiveData - get() = _questionnaireProgressStateLiveData - - val applicationConfiguration: ApplicationConfiguration by lazy { - configurationRegistry.retrieveConfiguration(ConfigType.Application) - } - - var uniqueIdResource: Resource? = null - - /** - * This function retrieves the [Questionnaire] as configured via the [QuestionnaireConfig]. The - * retrieved [Questionnaire] can then be pre-populated. - */ - suspend fun retrieveQuestionnaire( - questionnaireConfig: QuestionnaireConfig, - ): Questionnaire? { - if (questionnaireConfig.id.isEmpty() || questionnaireConfig.id.isBlank()) return null - return defaultRepository.loadResource(questionnaireConfig.id) - } - - /** - * This function performs data extraction against the [QuestionnaireResponse]. All the resources - * generated from a successful extraction by StructureMap or definition are stored in the - * database. The [QuestionnaireResponse] is also stored in the database regardless of the outcome - * of [ResourceMapper.extract]. This function will optionally generate CarePlan using the - * PlanDefinition resource configured in [QuestionnaireConfig.planDefinitions]. The - * [QuestionnaireConfig.eventWorkflows] contains configurations to cascade update the statuses of - * resources to in-active (close) that are related to the current [QuestionnaireResponse.subject] - */ - fun handleQuestionnaireSubmission( - questionnaire: Questionnaire, - currentQuestionnaireResponse: QuestionnaireResponse, - questionnaireConfig: QuestionnaireConfig, - actionParameters: List, - context: Context, - onSuccessfulSubmission: (List, QuestionnaireResponse) -> Unit, - ) { - viewModelScope.launch(SupervisorJob()) { - val questionnaireResponseValid = - validateQuestionnaireResponse( - questionnaire = questionnaire, - questionnaireResponse = currentQuestionnaireResponse, - context = context, - ) - - if (questionnaireConfig.saveQuestionnaireResponse && !questionnaireResponseValid) { - Timber.e("Invalid questionnaire response") - context.showToast(context.getString(R.string.questionnaire_response_invalid)) - setProgressState(QuestionnaireProgressState.ExtractionInProgress(false)) - return@launch - } + private val authenticatedOrganizationIds by lazy { + sharedPreferencesHelper.read>(ResourceType.Organization.name) + } + + private val practitionerId: String? by lazy { + sharedPreferencesHelper + .read(SharedPreferenceKey.PRACTITIONER_ID.name, null) + ?.extractLogicalIdUuid() + } + + private val _questionnaireProgressStateLiveData = MutableLiveData() + val questionnaireProgressStateLiveData: LiveData + get() = _questionnaireProgressStateLiveData + + val applicationConfiguration: ApplicationConfiguration by lazy { + configurationRegistry.retrieveConfiguration(ConfigType.Application) + } + + var uniqueIdResource: Resource? = null + + /** + * This function retrieves the [Questionnaire] as configured via the [QuestionnaireConfig]. The + * retrieved [Questionnaire] can then be pre-populated. + */ + suspend fun retrieveQuestionnaire( + questionnaireConfig: QuestionnaireConfig, + ): Questionnaire? { + if (questionnaireConfig.id.isEmpty() || questionnaireConfig.id.isBlank()) return null + return defaultRepository.loadResource(questionnaireConfig.id) + } + + /** + * This function performs data extraction against the [QuestionnaireResponse]. All the resources + * generated from a successful extraction by StructureMap or definition are stored in the + * database. The [QuestionnaireResponse] is also stored in the database regardless of the outcome + * of [ResourceMapper.extract]. This function will optionally generate CarePlan using the + * PlanDefinition resource configured in [QuestionnaireConfig.planDefinitions]. The + * [QuestionnaireConfig.eventWorkflows] contains configurations to cascade update the statuses of + * resources to in-active (close) that are related to the current [QuestionnaireResponse.subject] + */ + fun handleQuestionnaireSubmission( + questionnaire: Questionnaire, + currentQuestionnaireResponse: QuestionnaireResponse, + questionnaireConfig: QuestionnaireConfig, + actionParameters: List, + context: Context, + onSuccessfulSubmission: (List, QuestionnaireResponse) -> Unit, + ) { + viewModelScope.launch(SupervisorJob()) { + val questionnaireResponseValid = + validateQuestionnaireResponse( + questionnaire = questionnaire, + questionnaireResponse = currentQuestionnaireResponse, + context = context, + ) - currentQuestionnaireResponse.processMetadata( - questionnaire = questionnaire, - questionnaireConfig = questionnaireConfig, - context = context, - ) + if (questionnaireConfig.saveQuestionnaireResponse && !questionnaireResponseValid) { + Timber.e("Invalid questionnaire response") + context.showToast(context.getString(R.string.questionnaire_response_invalid)) + setProgressState(QuestionnaireProgressState.ExtractionInProgress(false)) + return@launch + } + + currentQuestionnaireResponse.processMetadata( + questionnaire = questionnaire, + questionnaireConfig = questionnaireConfig, + context = context, + ) + + val bundle = + performExtraction( + extractByStructureMap = questionnaire.extractByStructureMap(), + questionnaire = questionnaire, + questionnaireResponse = currentQuestionnaireResponse, + context = context, + ) - val bundle = - performExtraction( - extractByStructureMap = questionnaire.extractByStructureMap(), - questionnaire = questionnaire, - questionnaireResponse = currentQuestionnaireResponse, - context = context, - ) + saveExtractedResources( + bundle = bundle, + questionnaire = questionnaire, + questionnaireConfig = questionnaireConfig, + questionnaireResponse = currentQuestionnaireResponse, + context = context, + ) + + updateResourcesLastUpdatedProperty(actionParameters) + + // Important to load subject resource to retrieve ID (as reference) correctly + val subjectIdType: IdType? = + if (currentQuestionnaireResponse.subject.reference.isNullOrEmpty()) { + null + } else { + IdType(currentQuestionnaireResponse.subject.reference) + } - saveExtractedResources( - bundle = bundle, - questionnaire = questionnaire, - questionnaireConfig = questionnaireConfig, - questionnaireResponse = currentQuestionnaireResponse, - context = context, + if (subjectIdType != null) { + val subject = + loadResource( + ResourceType.valueOf(subjectIdType.resourceType), + subjectIdType.idPart, + ) + + if (subject != null && !questionnaireConfig.isReadOnly()) { + val newBundle = bundle.copyBundle(currentQuestionnaireResponse) + + val extractedResources = newBundle.entry.map { it.resource } + validateWithFhirValidator(*extractedResources.toTypedArray()) + + generateCarePlan( + subject = subject, + bundle = newBundle, + questionnaireConfig = questionnaireConfig, + ) + + withContext(dispatcherProvider.io()) { + executeCql( + subject = subject, + bundle = newBundle, + questionnaire = questionnaire, + questionnaireConfig = questionnaireConfig, ) + } - updateResourcesLastUpdatedProperty(actionParameters) - - // Important to load subject resource to retrieve ID (as reference) correctly - val subjectIdType: IdType? = - if (currentQuestionnaireResponse.subject.reference.isNullOrEmpty()) { - null - } else { - IdType(currentQuestionnaireResponse.subject.reference) - } - - if (subjectIdType != null) { - val subject = - loadResource( - ResourceType.valueOf(subjectIdType.resourceType), - subjectIdType.idPart, - ) - - if (subject != null && !questionnaireConfig.isReadOnly()) { - val newBundle = bundle.copyBundle(currentQuestionnaireResponse) - - val extractedResources = newBundle.entry.map { it.resource } - validateWithFhirValidator(*extractedResources.toTypedArray()) - - generateCarePlan( - subject = subject, - bundle = newBundle, - questionnaireConfig = questionnaireConfig, - ) - - withContext(dispatcherProvider.io()) { - executeCql( - subject = subject, - bundle = newBundle, - questionnaire = questionnaire, - questionnaireConfig = questionnaireConfig, - ) - } - - fhirCarePlanGenerator.conditionallyUpdateResourceStatus( - questionnaireConfig = questionnaireConfig, - subject = subject, - bundle = newBundle, - ) - } - } + fhirCarePlanGenerator.conditionallyUpdateResourceStatus( + questionnaireConfig = questionnaireConfig, + subject = subject, + bundle = newBundle, + ) + } + } - softDeleteResources(questionnaireConfig) + softDeleteResources(questionnaireConfig) - retireUsedQuestionnaireUniqueId(questionnaireConfig, currentQuestionnaireResponse) + retireUsedQuestionnaireUniqueId(questionnaireConfig, currentQuestionnaireResponse) - val idTypes = - bundle.entry?.map { IdType(it.resource.resourceType.name, it.resource.logicalId) } - ?: emptyList() + val idTypes = + bundle.entry?.map { IdType(it.resource.resourceType.name, it.resource.logicalId) } + ?: emptyList() - onSuccessfulSubmission( - idTypes, - currentQuestionnaireResponse, - ) - } + onSuccessfulSubmission( + idTypes, + currentQuestionnaireResponse, + ) } - - suspend fun validateWithFhirValidator(vararg resource: Resource) { - val fhirValidator = fhirValidatorProvider.get() - fhirValidator.checkResourceValid(*resource).logErrorMessages() + } + + fun validateWithFhirValidator(vararg resource: Resource) { + if (BuildConfig.DEBUG) { + fhirValidatorRequestHandlerProvider + .get() + .handleResourceValidationRequest( + request = + ResourceValidationRequest( + *resource, + ), + ) } - - suspend fun retireUsedQuestionnaireUniqueId( - questionnaireConfig: QuestionnaireConfig, - questionnaireResponse: QuestionnaireResponse, - ) { - if (questionnaireConfig.uniqueIdAssignment != null) { - val uniqueIdLinkId = questionnaireConfig.uniqueIdAssignment!!.linkId - val submittedUniqueId = - questionnaireResponse.find(uniqueIdLinkId)?.answer?.first()?.value.toString() - - // Update Group resource. Can be extended in future to support other resources - if (uniqueIdResource is Group) { - with(uniqueIdResource as Group) { - val characteristic = this.characteristic[this.quantity] - if ( - characteristic.hasValueCodeableConcept() && - characteristic.valueCodeableConcept.text == submittedUniqueId - ) { - characteristic.exclude = true - this.quantity++ - this.active = - this.quantity < - this.characteristic.size // Mark Group as inactive when all IDs are retired - defaultRepository.addOrUpdate(resource = this) - } - } - } - Timber.i( - "ID '$submittedUniqueId' used'", - ) + } + + suspend fun retireUsedQuestionnaireUniqueId( + questionnaireConfig: QuestionnaireConfig, + questionnaireResponse: QuestionnaireResponse, + ) { + if (questionnaireConfig.uniqueIdAssignment != null) { + val uniqueIdLinkId = questionnaireConfig.uniqueIdAssignment!!.linkId + val submittedUniqueId = + questionnaireResponse.find(uniqueIdLinkId)?.answer?.first()?.value.toString() + + // Update Group resource. Can be extended in future to support other resources + if (uniqueIdResource is Group) { + with(uniqueIdResource as Group) { + val characteristic = this.characteristic[this.quantity] + if ( + characteristic.hasValueCodeableConcept() && + characteristic.valueCodeableConcept.text == submittedUniqueId + ) { + characteristic.exclude = true + this.quantity++ + this.active = + this.quantity < + this.characteristic.size // Mark Group as inactive when all IDs are retired + defaultRepository.addOrUpdate(resource = this) + } } + } + Timber.i( + "ID '$submittedUniqueId' used'", + ) } + } + + suspend fun saveExtractedResources( + bundle: Bundle, + questionnaire: Questionnaire, + questionnaireConfig: QuestionnaireConfig, + questionnaireResponse: QuestionnaireResponse, + context: Context, + ) { + val extractionDate = Date() + + // Create a ListResource to store the references for generated resources + val listResource = + ListResource().apply { + id = UUID.randomUUID().toString() + status = ListResource.ListStatus.CURRENT + mode = ListResource.ListMode.WORKING + title = CONTAINED_LIST_TITLE + date = extractionDate + } + + val subjectType = questionnaireSubjectType(questionnaire, questionnaireConfig) + + val previouslyExtractedResources = + retrievePreviouslyExtractedResources( + questionnaireConfig = questionnaireConfig, + subjectType = subjectType, + questionnaire = questionnaire, + ) + + val extractedResourceUniquePropertyExpressionsMap = + questionnaireConfig.extractedResourceUniquePropertyExpressions?.associateBy { + it.resourceType + } ?: emptyMap() + + bundle.entry?.forEach { bundleEntryComponent -> + bundleEntryComponent.resource?.run { + applyResourceMetadata(questionnaireConfig, questionnaireResponse, context) + if ( + questionnaireResponse.subject.reference.isNullOrEmpty() && + subjectType != null && + resourceType == subjectType && + logicalId.isNotEmpty() + ) { + questionnaireResponse.subject = this.logicalId.asReference(subjectType) + } - suspend fun saveExtractedResources( - bundle: Bundle, - questionnaire: Questionnaire, - questionnaireConfig: QuestionnaireConfig, - questionnaireResponse: QuestionnaireResponse, - context: Context, - ) { - val extractionDate = Date() - - // Create a ListResource to store the references for generated resources - val listResource = - ListResource().apply { - id = UUID.randomUUID().toString() - status = ListResource.ListStatus.CURRENT - mode = ListResource.ListMode.WORKING - title = CONTAINED_LIST_TITLE - date = extractionDate - } - - val subjectType = questionnaireSubjectType(questionnaire, questionnaireConfig) - - val previouslyExtractedResources = - retrievePreviouslyExtractedResources( - questionnaireConfig = questionnaireConfig, - subjectType = subjectType, - questionnaire = questionnaire, - ) - - val extractedResourceUniquePropertyExpressionsMap = - questionnaireConfig.extractedResourceUniquePropertyExpressions?.associateBy { - it.resourceType - } ?: emptyMap() - - bundle.entry?.forEach { bundleEntryComponent -> - bundleEntryComponent.resource?.run { - applyResourceMetadata(questionnaireConfig, questionnaireResponse, context) - if ( - questionnaireResponse.subject.reference.isNullOrEmpty() && - subjectType != null && - resourceType == subjectType && - logicalId.isNotEmpty() - ) { - questionnaireResponse.subject = this.logicalId.asReference(subjectType) - } - if (questionnaireConfig.isEditable()) { - if (resourceType == subjectType) { - this.id = questionnaireResponse.subject.extractId() - } else if ( - extractedResourceUniquePropertyExpressionsMap.containsKey(resourceType) && - previouslyExtractedResources.containsKey( - resourceType, - ) - ) { - val fhirPathExpression = - extractedResourceUniquePropertyExpressionsMap - .getValue(resourceType) - .fhirPathExpression - - val currentResourceIdentifier = - fhirPathDataExtractor.extractValue( - base = this, - expression = fhirPathExpression, - ) - - // Search for resource with property value matching extracted value - val resource = - previouslyExtractedResources.getValue(resourceType).find { - val extractedValue = - fhirPathDataExtractor.extractValue( - base = it, - expression = fhirPathExpression, - ) - extractedValue.isNotEmpty() && - extractedValue.equals(currentResourceIdentifier, true) - } - - // Found match use the id on current resource; override identifiers for RelatedPerson - if (resource != null) { - this.id = resource.logicalId - if (this is RelatedPerson && resource is RelatedPerson) { - this.identifier = resource.identifier - } - } - } - } - - // Set Encounter on QR if the ResourceType is Encounter - if (this.resourceType == ResourceType.Encounter) { - questionnaireResponse.setEncounter(this.asReference()) - } - - // Set the Group's Related Entity Location metadata tag on Resource before saving. - this.applyRelatedEntityLocationMetaTag(questionnaireConfig, context, subjectType) - - defaultRepository.addOrUpdate(true, resource = this) - - updateGroupManagingEntity( - resource = this, - groupIdentifier = questionnaireConfig.groupResource?.groupIdentifier, - managingEntityRelationshipCode = questionnaireConfig.managingEntityRelationshipCode, - ) - addMemberToGroup( - resource = this, - memberResourceType = questionnaireConfig.groupResource?.memberResourceType, - groupIdentifier = questionnaireConfig.groupResource?.groupIdentifier, + if (questionnaireConfig.isEditable()) { + if (resourceType == subjectType) { + this.id = questionnaireResponse.subject.extractId() + } else if ( + extractedResourceUniquePropertyExpressionsMap.containsKey(resourceType) && + previouslyExtractedResources.containsKey( + resourceType, + ) + ) { + val fhirPathExpression = + extractedResourceUniquePropertyExpressionsMap + .getValue(resourceType) + .fhirPathExpression + + val currentResourceIdentifier = + withContext(dispatcherProvider.default()) { + fhirPathDataExtractor.extractValue( + base = this@run, + expression = fhirPathExpression, ) + } - // Track ids for resources in ListResource added to the QuestionnaireResponse.contained - val listEntryComponent = - ListEntryComponent().apply { - deleted = false - date = extractionDate - item = asReference() - } - listResource.addEntry(listEntryComponent) + // Search for resource with property value matching extracted value + val resource = + previouslyExtractedResources.getValue(resourceType).find { + val extractedValue = + withContext(dispatcherProvider.default()) { + fhirPathDataExtractor.extractValue( + base = it, + expression = fhirPathExpression, + ) + } + extractedValue.isNotEmpty() && + extractedValue.equals(currentResourceIdentifier, true) + } + + // Found match use the id on current resource; override identifiers for RelatedPerson + if (resource != null) { + this.id = resource.logicalId + if (this is RelatedPerson && resource is RelatedPerson) { + this.identifier = resource.identifier + } } + } } - // Reference extracted resources in QR then save it if subject exists - questionnaireResponse.apply { addContained(listResource) } - - if ( - !questionnaireResponse.subject.reference.isNullOrEmpty() && - questionnaireConfig.saveQuestionnaireResponse - ) { - // Set the Group's Related Entity Location meta tag on QuestionnaireResponse then save. - questionnaireResponse.applyRelatedEntityLocationMetaTag( - questionnaireConfig = questionnaireConfig, - context = context, - subjectType = subjectType, - ) - defaultRepository.addOrUpdate(resource = questionnaireResponse) + // Set Encounter on QR if the ResourceType is Encounter + if (this.resourceType == ResourceType.Encounter) { + questionnaireResponse.setEncounter(this.asReference()) } - } - - private suspend fun Resource.applyRelatedEntityLocationMetaTag( - questionnaireConfig: QuestionnaireConfig, - context: Context, - subjectType: ResourceType?, - ) { - val resourceIdPair = - when { - !questionnaireConfig.resourceIdentifier.isNullOrEmpty() && subjectType != null -> { - Pair(subjectType, questionnaireConfig.resourceIdentifier!!) - } - !questionnaireConfig.groupResource?.groupIdentifier.isNullOrEmpty() && - questionnaireConfig.groupResource?.removeGroup != true && - questionnaireConfig.groupResource?.removeMember != true -> { - Pair(ResourceType.Group, questionnaireConfig.groupResource!!.groupIdentifier) - } + // Set the Group's Related Entity Location metadata tag on Resource before saving. + this.applyRelatedEntityLocationMetaTag(questionnaireConfig, context, subjectType) - else -> null - } - if (resourceIdPair != null) { - val (resourceType, resourceId) = resourceIdPair - val resource = - loadResource(resourceType = resourceType, resourceIdentifier = resourceId) - var relatedEntityLocationTags = - resource?.meta?.tag?.filter { coding -> - coding.system == - context.getString( - org.smartregister.fhircore.engine.R.string - .sync_strategy_related_entity_location_system, - ) - } + defaultRepository.addOrUpdate(true, resource = this) - if (relatedEntityLocationTags.isNullOrEmpty()) { - relatedEntityLocationTags = - retrieveRelatedEntityTagsLinkedToSubject(context, resourceIdPair) - } + updateGroupManagingEntity( + resource = this, + groupIdentifier = questionnaireConfig.groupResource?.groupIdentifier, + managingEntityRelationshipCode = questionnaireConfig.managingEntityRelationshipCode, + ) + addMemberToGroup( + resource = this, + memberResourceType = questionnaireConfig.groupResource?.memberResourceType, + groupIdentifier = questionnaireConfig.groupResource?.groupIdentifier, + ) - relatedEntityLocationTags?.forEach { - val existingTag = this.meta.getTag(it.system, it.code) - if (existingTag == null) { - this.meta.addTag(it) - } - } - } + // Track ids for resources in ListResource added to the QuestionnaireResponse.contained + val listEntryComponent = + ListEntryComponent().apply { + deleted = false + date = extractionDate + item = asReference() + } + listResource.addEntry(listEntryComponent) + } } - private suspend fun retrieveRelatedEntityTagsLinkedToSubject( - context: Context, - resourceIdPair: Pair, - ): List? { - val system = - context.getString( - org.smartregister.fhircore.engine.R.string.sync_strategy_related_entity_location_system, - ) - val display = + // Reference extracted resources in QR then save it if subject exists + questionnaireResponse.apply { addContained(listResource) } + + if ( + !questionnaireResponse.subject.reference.isNullOrEmpty() && + questionnaireConfig.saveQuestionnaireResponse + ) { + // Set the Group's Related Entity Location meta tag on QuestionnaireResponse then save. + questionnaireResponse.applyRelatedEntityLocationMetaTag( + questionnaireConfig = questionnaireConfig, + context = context, + subjectType = subjectType, + ) + defaultRepository.addOrUpdate(resource = questionnaireResponse) + } + } + + private suspend fun Resource.applyRelatedEntityLocationMetaTag( + questionnaireConfig: QuestionnaireConfig, + context: Context, + subjectType: ResourceType?, + ) { + val resourceIdPair = + when { + !questionnaireConfig.resourceIdentifier.isNullOrEmpty() && subjectType != null -> { + Pair(subjectType, questionnaireConfig.resourceIdentifier!!) + } + !questionnaireConfig.groupResource?.groupIdentifier.isNullOrEmpty() && + questionnaireConfig.groupResource?.removeGroup != true && + questionnaireConfig.groupResource?.removeMember != true -> { + Pair(ResourceType.Group, questionnaireConfig.groupResource!!.groupIdentifier) + } + else -> null + } + if (resourceIdPair != null) { + val (resourceType, resourceId) = resourceIdPair + val resource = loadResource(resourceType = resourceType, resourceIdentifier = resourceId) + var relatedEntityLocationTags = + resource?.meta?.tag?.filter { coding -> + coding.system == context.getString( - org.smartregister.fhircore.engine.R.string.sync_strategy_related_entity_location_display, + org.smartregister.fhircore.engine.R.string + .sync_strategy_related_entity_location_system, ) - val (resourceType, resourceId) = resourceIdPair - - if (resourceType == ResourceType.Location) { - return listOf(Coding(system, resourceId, display)) } - applicationConfiguration.codingSystems - .find { it.usage == CodingSystemUsage.LOCATION_LINKAGE } - ?.coding - ?.let { linkageResourceCode -> - val search = - Search(ResourceType.List).apply { - filter( - ListResource.CODE, - { - value = - of( - Coding( - linkageResourceCode.system, - linkageResourceCode.code, - linkageResourceCode.display, - ), - ) - }, - ) - filter(ListResource.ITEM, { value = "$resourceType/$resourceId" }) - } - - return defaultRepository.search(search).map { listResource -> - Coding(system, listResource.subject.extractId(), display) - } - } - - return null - } + if (relatedEntityLocationTags.isNullOrEmpty()) { + relatedEntityLocationTags = + retrieveRelatedEntityTagsLinkedToSubject(context, resourceIdPair) + } - private suspend fun retrievePreviouslyExtractedResources( - questionnaireConfig: QuestionnaireConfig, - subjectType: ResourceType?, - questionnaire: Questionnaire, - ): MutableMap> { - val referencedResources = mutableMapOf>() - if ( - questionnaireConfig.isEditable() && - !questionnaireConfig.resourceIdentifier.isNullOrEmpty() && - subjectType != null - ) { - searchQuestionnaireResponse( - resourceId = questionnaireConfig.resourceIdentifier!!, - resourceType = questionnaireConfig.resourceType ?: subjectType, - questionnaireId = questionnaire.logicalId, - encounterId = questionnaireConfig.encounterId, - ) - ?.contained - ?.asSequence() - ?.filterIsInstance() - ?.filter { it.title.equals(CONTAINED_LIST_TITLE, true) } - ?.flatMap { it.entry } - ?.forEach { - val idType = IdType(it.item.reference) - val resource = - loadResource(ResourceType.fromCode(idType.resourceType), idType.idPart) - if (resource != null) { - referencedResources.getOrPut(resource.resourceType) { mutableListOf() } - .add(resource) - } - } + relatedEntityLocationTags?.forEach { + val existingTag = this.meta.getTag(it.system, it.code) + if (existingTag == null) { + this.meta.addTag(it) } - return referencedResources + } + } + } + + private suspend fun retrieveRelatedEntityTagsLinkedToSubject( + context: Context, + resourceIdPair: Pair, + ): List? { + val system = + context.getString( + org.smartregister.fhircore.engine.R.string.sync_strategy_related_entity_location_system, + ) + val display = + context.getString( + org.smartregister.fhircore.engine.R.string.sync_strategy_related_entity_location_display, + ) + val (resourceType, resourceId) = resourceIdPair + + if (resourceType == ResourceType.Location) { + return listOf(Coding(system, resourceId, display)) } - private fun Bundle.copyBundle(currentQuestionnaireResponse: QuestionnaireResponse): Bundle = - this.copy().apply { - addEntry( - Bundle.BundleEntryComponent().apply { resource = currentQuestionnaireResponse }, + applicationConfiguration.codingSystems + .find { it.usage == CodingSystemUsage.LOCATION_LINKAGE } + ?.coding + ?.let { linkageResourceCode -> + val search = + Search(ResourceType.List).apply { + filter( + ListResource.CODE, + { + value = + of( + Coding( + linkageResourceCode.system, + linkageResourceCode.code, + linkageResourceCode.display, + ), + ) + }, ) - } + filter(ListResource.ITEM, { value = "$resourceType/$resourceId" }) + } - private fun QuestionnaireResponse.processMetadata( - questionnaire: Questionnaire, - questionnaireConfig: QuestionnaireConfig, - context: Context, + return defaultRepository.search(search).map { listResource -> + Coding(system, listResource.subject.extractId(), display) + } + } + + return null + } + + private suspend fun retrievePreviouslyExtractedResources( + questionnaireConfig: QuestionnaireConfig, + subjectType: ResourceType?, + questionnaire: Questionnaire, + ): MutableMap> { + val referencedResources = mutableMapOf>() + if ( + questionnaireConfig.isEditable() && + !questionnaireConfig.resourceIdentifier.isNullOrEmpty() && + subjectType != null ) { - status = QuestionnaireResponse.QuestionnaireResponseStatus.COMPLETED - authored = Date() - - questionnaire.useContext - .filter { it.hasValueCodeableConcept() } - .forEach { it.valueCodeableConcept.coding.forEach { coding -> this.meta.addTag(coding) } } - applyResourceMetadata(questionnaireConfig, this, context) - setQuestionnaire("${questionnaire.resourceType}/${questionnaire.logicalId}") - - // Set subject if exists - val resourceType = questionnaireSubjectType(questionnaire, questionnaireConfig) - val resourceIdentifier = questionnaireConfig.resourceIdentifier - if (resourceType != null && !resourceIdentifier.isNullOrEmpty()) { - subject = resourceIdentifier.asReference(resourceType) + searchQuestionnaireResponse( + resourceId = questionnaireConfig.resourceIdentifier!!, + resourceType = questionnaireConfig.resourceType ?: subjectType, + questionnaireId = questionnaire.logicalId, + encounterId = questionnaireConfig.encounterId, + questionnaireResponseStatus = questionnaireConfig.questionnaireResponseStatus(), + ) + ?.contained + ?.asSequence() + ?.filterIsInstance() + ?.filter { it.title.equals(CONTAINED_LIST_TITLE, true) } + ?.flatMap { it.entry } + ?.forEach { + val idType = IdType(it.item.reference) + val resource = loadResource(ResourceType.fromCode(idType.resourceType), idType.idPart) + if (resource != null) { + referencedResources.getOrPut(resource.resourceType) { mutableListOf() }.add(resource) + } } } - - private fun questionnaireSubjectType( - questionnaire: Questionnaire, - questionnaireConfig: QuestionnaireConfig, - ): ResourceType? { - val questionnaireSubjectType = questionnaire.subjectType.firstOrNull()?.code - return questionnaireConfig.resourceType - ?: questionnaireSubjectType?.let { - ResourceType.valueOf( - it, - ) - } + return referencedResources + } + + private fun Bundle.copyBundle(currentQuestionnaireResponse: QuestionnaireResponse): Bundle = + this.copy().apply { + addEntry( + Bundle.BundleEntryComponent().apply { resource = currentQuestionnaireResponse }, + ) } - private fun Resource?.applyResourceMetadata( - questionnaireConfig: QuestionnaireConfig, - questionnaireResponse: QuestionnaireResponse, - context: Context, - ) = - this?.apply { - appendOrganizationInfo(authenticatedOrganizationIds) - appendPractitionerInfo(practitionerId) - appendRelatedEntityLocation(questionnaireResponse, questionnaireConfig, context) - updateLastUpdated() - generateMissingId() - } + private fun QuestionnaireResponse.processMetadata( + questionnaire: Questionnaire, + questionnaireConfig: QuestionnaireConfig, + context: Context, + ) { + status = QuestionnaireResponse.QuestionnaireResponseStatus.COMPLETED + authored = Date() + + questionnaire.useContext + .filter { it.hasValueCodeableConcept() } + .forEach { it.valueCodeableConcept.coding.forEach { coding -> this.meta.addTag(coding) } } + applyResourceMetadata(questionnaireConfig, this, context) + setQuestionnaire("${questionnaire.resourceType}/${questionnaire.logicalId}") + + // Set subject if exists + val resourceType = questionnaireSubjectType(questionnaire, questionnaireConfig) + val resourceIdentifier = questionnaireConfig.resourceIdentifier + if (resourceType != null && !resourceIdentifier.isNullOrEmpty()) { + subject = resourceIdentifier.asReference(resourceType) + } + } + + private fun questionnaireSubjectType( + questionnaire: Questionnaire, + questionnaireConfig: QuestionnaireConfig, + ): ResourceType? { + val questionnaireSubjectType = questionnaire.subjectType.firstOrNull()?.code + return questionnaireConfig.resourceType + ?: questionnaireSubjectType?.let { + ResourceType.valueOf( + it, + ) + } + } + + private fun Resource?.applyResourceMetadata( + questionnaireConfig: QuestionnaireConfig, + questionnaireResponse: QuestionnaireResponse, + context: Context, + ) = + this?.apply { + appendOrganizationInfo(authenticatedOrganizationIds) + appendPractitionerInfo(practitionerId) + appendRelatedEntityLocation(questionnaireResponse, questionnaireConfig, context) + updateLastUpdated() + generateMissingId() + } - /** - * Perform StructureMap or Definition based definition. The result of this function returns a - * Bundle that contains the resources that were generated via the [ResourceMapper.extract] - * operation otherwise returns null if an exception is encountered. - */ - suspend fun performExtraction( - extractByStructureMap: Boolean, - questionnaire: Questionnaire, - questionnaireResponse: QuestionnaireResponse, - context: Context, - ): Bundle = - kotlin - .runCatching { - if (extractByStructureMap) { - ResourceMapper.extract( - questionnaire = questionnaire, - questionnaireResponse = questionnaireResponse, - structureMapExtractionContext = - StructureMapExtractionContext( - transformSupportServices = transformSupportServices, - structureMapProvider = { structureMapUrl: String?, _: IWorkerContext -> - structureMapUrl?.substringAfterLast("/")?.let { - defaultRepository.loadResource(it) - } - }, - ), - ) - } else { - ResourceMapper.extract( - questionnaire = questionnaire, - questionnaireResponse = questionnaireResponse, - ) - } - } - .onFailure { exception -> - Timber.e(exception) - viewModelScope.launch(dispatcherProvider.main()) { - if (exception is NullPointerException && exception.message!!.contains("StructureMap")) { - context.showToast( - context.getString(R.string.structure_map_missing_message), - Toast.LENGTH_LONG, - ) - } else { - context.showToast( - context.getString(R.string.structuremap_failed, questionnaire.name), - Toast.LENGTH_LONG, - ) + /** + * Perform StructureMap or Definition based definition. The result of this function returns a + * Bundle that contains the resources that were generated via the [ResourceMapper.extract] + * operation otherwise returns null if an exception is encountered. + */ + suspend fun performExtraction( + extractByStructureMap: Boolean, + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + context: Context, + ): Bundle = + kotlin + .runCatching { + withContext(dispatcherProvider.default()) { + if (extractByStructureMap) { + ResourceMapper.extract( + questionnaire = questionnaire, + questionnaireResponse = questionnaireResponse, + structureMapExtractionContext = + StructureMapExtractionContext( + transformSupportServices = transformSupportServices, + structureMapProvider = { structureMapUrl: String?, _: IWorkerContext -> + structureMapUrl?.substringAfterLast("/")?.let { + defaultRepository.loadResource(it) } - } - } - .getOrDefault(Bundle()) - - /** - * This function saves [QuestionnaireResponse] as draft if any of the [QuestionnaireResponse.item] - * has an answer. - */ - fun saveDraftQuestionnaire(questionnaireResponse: QuestionnaireResponse) { - viewModelScope.launch { - val questionnaireHasAnswer = - questionnaireResponse.item.any { - it.answer.any { answerComponent -> answerComponent.hasValue() } - } - if (questionnaireHasAnswer) { - questionnaireResponse.status = - QuestionnaireResponse.QuestionnaireResponseStatus.INPROGRESS - defaultRepository.addOrUpdate( - addMandatoryTags = true, - resource = questionnaireResponse, - ) + }, + ), + ) + } else { + ResourceMapper.extract( + questionnaire = questionnaire, + questionnaireResponse = questionnaireResponse, + ) + } + } + } + .onFailure { exception -> + Timber.e(exception) + viewModelScope.launch(dispatcherProvider.main()) { + if (exception is NullPointerException && exception.message!!.contains("StructureMap")) { + context.showToast( + context.getString(R.string.structure_map_missing_message), + Toast.LENGTH_LONG, + ) + } else { + context.showToast( + context.getString(R.string.structuremap_failed, questionnaire.name), + Toast.LENGTH_LONG, + ) + } + } + } + .getOrDefault(Bundle()) + + /** + * This function saves [QuestionnaireResponse] as draft if any of the [QuestionnaireResponse.item] + * has an answer. + */ + fun saveDraftQuestionnaire( + questionnaireResponse: QuestionnaireResponse, + questionnaireConfig: QuestionnaireConfig, + ) { + viewModelScope.launch { + val hasPages = questionnaireResponse.item.any { it.hasItem() } + val questionnaireHasAnswer = + questionnaireResponse.item.any { + if (!hasPages) { + it.answer.any { answerComponent -> answerComponent.hasValue() } + } else { + questionnaireResponse.item.any { page -> + page.item.any { pageItem -> + pageItem.answer.any { answerComponent -> answerComponent.hasValue() } + } } + } } + questionnaireResponse.questionnaire = + questionnaireConfig.id.asReference(ResourceType.Questionnaire).reference + if ( + !questionnaireConfig.resourceIdentifier.isNullOrBlank() && + questionnaireConfig.resourceType != null + ) { + questionnaireResponse.subject = + questionnaireConfig.resourceIdentifier!!.asReference( + questionnaireConfig.resourceType!!, + ) + } + if (questionnaireHasAnswer) { + questionnaireResponse.status = QuestionnaireResponse.QuestionnaireResponseStatus.INPROGRESS + defaultRepository.addOrUpdate( + addMandatoryTags = true, + resource = questionnaireResponse, + ) + } } - - /** - * This function updates the _lastUpdated property of resources configured by the - * [ActionParameter.paramType] of [ActionParameterType.UPDATE_DATE_ON_EDIT]. Each time a - * questionnaire is submitted, the affected resources last modified/updated date will also be - * updated. - */ - suspend fun updateResourcesLastUpdatedProperty(actionParameters: List?) { - val updateOnEditParams = - actionParameters?.filter { - it.paramType == ActionParameterType.UPDATE_DATE_ON_EDIT && it.value.isNotEmpty() - } - - updateOnEditParams?.forEach { param -> - try { - defaultRepository.run { - val resourceType = param.resourceType - if (resourceType != null) { - loadResource(resourceType, param.value)?.let { addOrUpdate(resource = it) } - } else { - val valueResourceType = param.value.substringBefore("/") - val valueResourceId = param.value.substringAfter("/") - addOrUpdate( - resource = - loadResource( - valueResourceId, - ResourceType.valueOf(valueResourceType), - ), - ) - } - } - } catch (resourceNotFoundException: ResourceNotFoundException) { - Timber.e("Unable to update resource's _lastUpdated", resourceNotFoundException) - } catch (illegalArgumentException: IllegalArgumentException) { - Timber.e( - "No enum constant org.hl7.fhir.r4.model.ResourceType.${ + } + + /** + * This function updates the _lastUpdated property of resources configured by the + * [ActionParameter.paramType] of [ActionParameterType.UPDATE_DATE_ON_EDIT]. Each time a + * questionnaire is submitted, the affected resources last modified/updated date will also be + * updated. + */ + suspend fun updateResourcesLastUpdatedProperty(actionParameters: List?) { + val updateOnEditParams = + actionParameters?.filter { + it.paramType == ActionParameterType.UPDATE_DATE_ON_EDIT && it.value.isNotEmpty() + } + + updateOnEditParams?.forEach { param -> + try { + defaultRepository.run { + val resourceType = param.resourceType + if (resourceType != null) { + loadResource(resourceType, param.value)?.let { addOrUpdate(resource = it) } + } else { + val valueResourceType = param.value.substringBefore("/") + val valueResourceId = param.value.substringAfter("/") + addOrUpdate( + resource = + loadResource( + valueResourceId, + ResourceType.valueOf(valueResourceType), + ), + ) + } + } + } catch (resourceNotFoundException: ResourceNotFoundException) { + Timber.e("Unable to update resource's _lastUpdated", resourceNotFoundException) + } catch (illegalArgumentException: IllegalArgumentException) { + Timber.e( + "No enum constant org.hl7.fhir.r4.model.ResourceType.${ param.value.substringBefore( "/", ) }", - ) - } - } + ) + } + } + } + + /** + * This function validates all [QuestionnaireResponse] and returns true if all the validation + * result of [QuestionnaireResponseValidator] are [Valid] or [NotValidated] (validation is + * optional on [Questionnaire] fields) + */ + suspend fun validateQuestionnaireResponse( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + context: Context, + ): Boolean { + val validQuestionnaireResponseItems = mutableListOf() + val validQuestionnaireItems = mutableListOf() + val questionnaireItemsMap = questionnaire.item.associateBy { it.linkId } + + // Only validate items that are present on both Questionnaire and the QuestionnaireResponse + questionnaireResponse.copy().item.forEach { + if (questionnaireItemsMap.containsKey(it.linkId)) { + val questionnaireItem = questionnaireItemsMap.getValue(it.linkId) + validQuestionnaireResponseItems.add(it) + validQuestionnaireItems.add(questionnaireItem) + } } - /** - * This function validates all [QuestionnaireResponse] and returns true if all the validation - * result of [QuestionnaireResponseValidator] are [Valid] or [NotValidated] (validation is - * optional on [Questionnaire] fields) - */ - suspend fun validateQuestionnaireResponse( - questionnaire: Questionnaire, - questionnaireResponse: QuestionnaireResponse, - context: Context, - ): Boolean { - val validQuestionnaireResponseItems = mutableListOf() - val validQuestionnaireItems = mutableListOf() - val questionnaireItemsMap = questionnaire.item.associateBy { it.linkId } - - // Only validate items that are present on both Questionnaire and the QuestionnaireResponse - questionnaireResponse.copy().item.forEach { - if (questionnaireItemsMap.containsKey(it.linkId)) { - val questionnaireItem = questionnaireItemsMap.getValue(it.linkId) - validQuestionnaireResponseItems.add(it) - validQuestionnaireItems.add(questionnaireItem) - } - } - - return QuestionnaireResponseValidator.validateQuestionnaireResponse( - questionnaire = Questionnaire().apply { item = validQuestionnaireItems }, - questionnaireResponse = - QuestionnaireResponse().apply { - item = validQuestionnaireResponseItems - packRepeatedGroups() - }, - context = context, + return withContext(dispatcherProvider.default()) { + QuestionnaireResponseValidator.validateQuestionnaireResponse( + questionnaire = Questionnaire().apply { item = validQuestionnaireItems }, + questionnaireResponse = + QuestionnaireResponse().apply { + item = validQuestionnaireResponseItems + packRepeatedGroups() + }, + context = context, ) - .values - .flatten() - .all { it is Valid || it is NotValidated } + .values + .flatten() + .all { it is Valid || it is NotValidated } + } + } + + suspend fun executeCql( + subject: Resource, + bundle: Bundle, + questionnaire: Questionnaire, + questionnaireConfig: QuestionnaireConfig? = null, + ) { + questionnaireConfig?.cqlInputResources?.forEach { resourceId -> + val basicResource = defaultRepository.loadResource(resourceId) as Basic? + bundle.addEntry(Bundle.BundleEntryComponent().setResource(basicResource)) } - suspend fun executeCql( - subject: Resource, - bundle: Bundle, - questionnaire: Questionnaire, - questionnaireConfig: QuestionnaireConfig? = null, - ) { - questionnaireConfig?.cqlInputResources?.forEach { resourceId -> - val basicResource = defaultRepository.loadResource(resourceId) as Basic? - bundle.addEntry(Bundle.BundleEntryComponent().setResource(basicResource)) + val libraryFilters = + questionnaire.cqfLibraryUrls().map { + val apply: TokenParamFilterCriterion.() -> Unit = { value = of(it.extractLogicalIdUuid()) } + apply + } + + if (libraryFilters.isNotEmpty()) { + defaultRepository.fhirEngine + .batchedSearch { + filter( + Resource.RES_ID, + *libraryFilters.toTypedArray(), + ) } - - val libraryFilters = - questionnaire.cqfLibraryUrls().map { - val apply: TokenParamFilterCriterion.() -> Unit = - { value = of(it.extractLogicalIdUuid()) } - apply - } - - if (libraryFilters.isNotEmpty()) { - defaultRepository.fhirEngine - .batchedSearch { - filter( - Resource.RES_ID, - *libraryFilters.toTypedArray(), - ) - } - .forEach { librarySearchResult -> - val result: Parameters = - fhirOperator.evaluateLibrary( - librarySearchResult.resource.url, - subject.asReference().reference, - null, - bundle, - null, - ) as Parameters - - val resources = - result.parameter.mapNotNull { cqlResultParameterComponent -> - (cqlResultParameterComponent.value - ?: cqlResultParameterComponent.resource)?.let { resultParameterResource -> - if (BuildConfig.DEBUG) { - Timber.d( - "CQL :: Param found: ${cqlResultParameterComponent.name} with value: ${ + .forEach { librarySearchResult -> + val result: Parameters = + fhirOperator.evaluateLibrary( + librarySearchResult.resource.url, + subject.asReference().reference, + null, + bundle, + null, + ) as Parameters + + val resources = + result.parameter.mapNotNull { cqlResultParameterComponent -> + (cqlResultParameterComponent.value ?: cqlResultParameterComponent.resource)?.let { + resultParameterResource -> + if (BuildConfig.DEBUG) { + Timber.d( + "CQL :: Param found: ${cqlResultParameterComponent.name} with value: ${ getStringRepresentation( resultParameterResource, ) }", - ) - } - - if ( - cqlResultParameterComponent.name.equals(OUTPUT_PARAMETER_KEY) && - resultParameterResource.isResource - ) { - defaultRepository.create( - true, - resultParameterResource as Resource - ) - resultParameterResource - } else { - null - } - } - } - - validateWithFhirValidator(*resources.toTypedArray()) + ) } - } - } - private fun getStringRepresentation(base: Base): String = - if (base.isResource) { - FhirContext.forR4Cached().newJsonParser().encodeResourceToString(base as Resource) - } else base.toString() - - /** - * This function generates CarePlans for the [QuestionnaireResponse.subject] using the configured - * [QuestionnaireConfig.planDefinitions] - */ - suspend fun generateCarePlan( - subject: Resource, - bundle: Bundle, - questionnaireConfig: QuestionnaireConfig, - ) { - questionnaireConfig.planDefinitions?.forEach { planId -> - if (planId.isNotEmpty()) { - kotlin - .runCatching { - val carePlan = - fhirCarePlanGenerator.generateOrUpdateCarePlan( - planDefinitionId = planId, - subject = subject, - data = bundle, - generateCarePlanWithWorkflowApi = - questionnaireConfig.generateCarePlanWithWorkflowApi, - ) - carePlan?.let { validateWithFhirValidator(it) } - } - .onFailure { Timber.e(it) } + if ( + cqlResultParameterComponent.name.equals(OUTPUT_PARAMETER_KEY) && + resultParameterResource.isResource + ) { + defaultRepository.create(true, resultParameterResource as Resource) + resultParameterResource + } else { + null + } + } } - } - } - /** Update the [Group.managingEntity] */ - private suspend fun updateGroupManagingEntity( - resource: Resource, - groupIdentifier: String?, - managingEntityRelationshipCode: String?, - ) { - // Load the group from the database to get the updated Resource always. - val group = - groupIdentifier?.extractLogicalIdUuid()?.let { loadResource(ResourceType.Group, it) } - as Group? - - if ( - group != null && - resource is RelatedPerson && - !resource.relationshipFirstRep.codingFirstRep.code.isNullOrEmpty() && - resource.relationshipFirstRep.codingFirstRep.code == managingEntityRelationshipCode - ) { - defaultRepository.addOrUpdate( - resource = group.apply { managingEntity = resource.asReference() }, - ) + validateWithFhirValidator(*resources.toTypedArray()) } } - - /** - * Adds [Resource] to [Group.member] if the member does not exist and if [Resource.logicalId] is - * NOT the same as the retrieved [GroupResourceConfig.groupIdentifier] (Cannot add a [Group] as - * member of itself. - */ - suspend fun addMemberToGroup( - resource: Resource, - memberResourceType: ResourceType?, - groupIdentifier: String?, - ) { - // Load the Group resource from the database to get the updated one - val group = - groupIdentifier?.extractLogicalIdUuid()?.let { loadResource(ResourceType.Group, it) } - as Group? ?: return - - val reference = resource.asReference() - val member = group.member.find { it.entity.reference.equals(reference.reference, true) } - - // Cannot add Group as member of itself; Cannot not duplicate existing members - if (resource.logicalId == group.logicalId || member != null) return - - if ( - resource.resourceType.isIn( - ResourceType.CareTeam, - ResourceType.Device, - ResourceType.Group, - ResourceType.HealthcareService, - ResourceType.Location, - ResourceType.Organization, - ResourceType.Patient, - ResourceType.Practitioner, - ResourceType.PractitionerRole, - ResourceType.Specimen, - ) && resource.resourceType == memberResourceType - ) { - group.addMember(Group.GroupMemberComponent().apply { entity = reference }) - defaultRepository.addOrUpdate(resource = group) - } + } + + private fun getStringRepresentation(base: Base): String = + if (base.isResource) { + FhirContext.forR4Cached().newJsonParser().encodeResourceToString(base as Resource) + } else base.toString() + + /** + * This function generates CarePlans for the [QuestionnaireResponse.subject] using the configured + * [QuestionnaireConfig.planDefinitions] + */ + suspend fun generateCarePlan( + subject: Resource, + bundle: Bundle, + questionnaireConfig: QuestionnaireConfig, + ) { + questionnaireConfig.planDefinitions?.forEach { planId -> + if (planId.isNotEmpty()) { + kotlin + .runCatching { + val carePlan = + fhirCarePlanGenerator.generateOrUpdateCarePlan( + planDefinitionId = planId, + subject = subject, + data = bundle, + generateCarePlanWithWorkflowApi = + questionnaireConfig.generateCarePlanWithWorkflowApi, + ) + carePlan?.let { validateWithFhirValidator(it) } + } + .onFailure { Timber.e(it) } + } } - - /** - * This function triggers removal of [Resource] s as per the [QuestionnaireConfig.groupResource] - * or [QuestionnaireConfig.removeResource] config properties. - */ - suspend fun softDeleteResources(questionnaireConfig: QuestionnaireConfig) { - if (questionnaireConfig.groupResource != null) { - removeGroup( - groupId = questionnaireConfig.groupResource!!.groupIdentifier, - removeGroup = questionnaireConfig.groupResource?.removeGroup ?: false, - deactivateMembers = questionnaireConfig.groupResource!!.deactivateMembers, - ) - removeGroupMember( - memberId = questionnaireConfig.resourceIdentifier, - removeMember = questionnaireConfig.groupResource?.removeMember ?: false, - groupIdentifier = questionnaireConfig.groupResource!!.groupIdentifier, - memberResourceType = questionnaireConfig.groupResource!!.memberResourceType, - ) - } - - if ( - questionnaireConfig.removeResource == true && - questionnaireConfig.resourceType != null && - !questionnaireConfig.resourceIdentifier.isNullOrEmpty() - ) { - viewModelScope.launch { - defaultRepository.delete( - resourceType = questionnaireConfig.resourceType!!, - resourceId = questionnaireConfig.resourceIdentifier!!, - softDelete = true, - ) - } - } + } + + /** Update the [Group.managingEntity] */ + private suspend fun updateGroupManagingEntity( + resource: Resource, + groupIdentifier: String?, + managingEntityRelationshipCode: String?, + ) { + // Load the group from the database to get the updated Resource always. + val group = + groupIdentifier?.extractLogicalIdUuid()?.let { loadResource(ResourceType.Group, it) } + as Group? + + if ( + group != null && + resource is RelatedPerson && + !resource.relationshipFirstRep.codingFirstRep.code.isNullOrEmpty() && + resource.relationshipFirstRep.codingFirstRep.code == managingEntityRelationshipCode + ) { + defaultRepository.addOrUpdate( + resource = group.apply { managingEntity = resource.asReference() }, + ) } - - private suspend fun removeGroup( - groupId: String, - removeGroup: Boolean, - deactivateMembers: Boolean, + } + + /** + * Adds [Resource] to [Group.member] if the member does not exist and if [Resource.logicalId] is + * NOT the same as the retrieved [GroupResourceConfig.groupIdentifier] (Cannot add a [Group] as + * member of itself. + */ + suspend fun addMemberToGroup( + resource: Resource, + memberResourceType: ResourceType?, + groupIdentifier: String?, + ) { + // Load the Group resource from the database to get the updated one + val group = + groupIdentifier?.extractLogicalIdUuid()?.let { loadResource(ResourceType.Group, it) } + as Group? ?: return + + val reference = resource.asReference() + val member = group.member.find { it.entity.reference.equals(reference.reference, true) } + + // Cannot add Group as member of itself; Cannot not duplicate existing members + if (resource.logicalId == group.logicalId || member != null) return + + if ( + resource.resourceType.isIn( + ResourceType.CareTeam, + ResourceType.Device, + ResourceType.Group, + ResourceType.HealthcareService, + ResourceType.Location, + ResourceType.Organization, + ResourceType.Patient, + ResourceType.Practitioner, + ResourceType.PractitionerRole, + ResourceType.Specimen, + ) && resource.resourceType == memberResourceType ) { - if (removeGroup) { - try { - defaultRepository.removeGroup( - groupId = groupId, - isDeactivateMembers = deactivateMembers, - configComputedRuleValues = emptyMap(), - ) - } catch (exception: Exception) { - Timber.e(exception) - } - } + group.addMember(Group.GroupMemberComponent().apply { entity = reference }) + defaultRepository.addOrUpdate(resource = group) + } + } + + /** + * This function triggers removal of [Resource] s as per the [QuestionnaireConfig.groupResource] + * or [QuestionnaireConfig.removeResource] config properties. + */ + suspend fun softDeleteResources(questionnaireConfig: QuestionnaireConfig) { + if (questionnaireConfig.groupResource != null) { + removeGroup( + groupId = questionnaireConfig.groupResource!!.groupIdentifier, + removeGroup = questionnaireConfig.groupResource?.removeGroup ?: false, + deactivateMembers = questionnaireConfig.groupResource!!.deactivateMembers, + ) + removeGroupMember( + memberId = questionnaireConfig.resourceIdentifier, + removeMember = questionnaireConfig.groupResource?.removeMember ?: false, + groupIdentifier = questionnaireConfig.groupResource!!.groupIdentifier, + memberResourceType = questionnaireConfig.groupResource!!.memberResourceType, + ) } - private suspend fun removeGroupMember( - memberId: String?, - groupIdentifier: String?, - memberResourceType: ResourceType?, - removeMember: Boolean, + if ( + questionnaireConfig.removeResource == true && + questionnaireConfig.resourceType != null && + !questionnaireConfig.resourceIdentifier.isNullOrEmpty() ) { - if (removeMember && !memberId.isNullOrEmpty()) { - try { - defaultRepository.removeGroupMember( - memberId = memberId, - groupId = groupIdentifier, - groupMemberResourceType = memberResourceType, - configComputedRuleValues = emptyMap(), - ) - } catch (exception: Exception) { - Timber.e(exception) - } - } + viewModelScope.launch { + defaultRepository.delete( + resourceType = questionnaireConfig.resourceType!!, + resourceId = questionnaireConfig.resourceIdentifier!!, + softDelete = true, + ) + } } - - /** - * This function searches and returns the latest [QuestionnaireResponse] for the given - * [resourceId] that was extracted from the [Questionnaire] identified as [questionnaireId]. - * Returns null if non is found. - */ - suspend fun searchQuestionnaireResponse( - resourceId: String, - resourceType: ResourceType, - questionnaireId: String, - encounterId: String?, - ): QuestionnaireResponse? { - val search = - Search(ResourceType.QuestionnaireResponse).apply { - filter( - QuestionnaireResponse.SUBJECT, - { value = resourceId.asReference(resourceType).reference }, - ) - filter( - QuestionnaireResponse.QUESTIONNAIRE, - { value = questionnaireId.asReference(ResourceType.Questionnaire).reference }, - ) - if (!encounterId.isNullOrBlank()) { - filter( - QuestionnaireResponse.ENCOUNTER, - { - value = - encounterId.extractLogicalIdUuid() - .asReference(ResourceType.Encounter).reference - }, - ) - } - } - val questionnaireResponses: List = defaultRepository.search(search) - return questionnaireResponses.maxByOrNull { it.meta.lastUpdated } + } + + private suspend fun removeGroup( + groupId: String, + removeGroup: Boolean, + deactivateMembers: Boolean, + ) { + if (removeGroup) { + try { + defaultRepository.removeGroup( + groupId = groupId, + isDeactivateMembers = deactivateMembers, + configComputedRuleValues = emptyMap(), + ) + } catch (exception: Exception) { + Timber.e(exception) + } } - - private suspend fun launchContextResources( - subjectResourceType: ResourceType?, - subjectResourceIdentifier: String?, - actionParameters: List, - ): List { - return when { - subjectResourceType != null && subjectResourceIdentifier != null -> - mutableListOf().apply { - loadResource(subjectResourceType, subjectResourceIdentifier)?.let { add(it) } - val actionParametersExcludingSubject = - actionParameters.filterNot { - it.paramType == ActionParameterType.QUESTIONNAIRE_RESPONSE_POPULATION_RESOURCE && - subjectResourceType == it.resourceType && - subjectResourceIdentifier.equals(it.value, ignoreCase = true) - } - addAll(retrievePopulationResources(actionParametersExcludingSubject)) - } - - else -> retrievePopulationResources(actionParameters) - } + } + + private suspend fun removeGroupMember( + memberId: String?, + groupIdentifier: String?, + memberResourceType: ResourceType?, + removeMember: Boolean, + ) { + if (removeMember && !memberId.isNullOrEmpty()) { + try { + defaultRepository.removeGroupMember( + memberId = memberId, + groupId = groupIdentifier, + groupMemberResourceType = memberResourceType, + configComputedRuleValues = emptyMap(), + ) + } catch (exception: Exception) { + Timber.e(exception) + } } - - suspend fun populateQuestionnaire( - questionnaire: Questionnaire, - questionnaireConfig: QuestionnaireConfig, - actionParameters: List, - ): Pair> { - val questionnaireSubjectType = questionnaire.subjectType.firstOrNull()?.code - val resourceType = - questionnaireConfig.resourceType - ?: questionnaireSubjectType?.let { ResourceType.valueOf(it) } - val resourceIdentifier = questionnaireConfig.resourceIdentifier - - val launchContextResources = - launchContextResources(resourceType, resourceIdentifier, actionParameters) - - // Populate questionnaire with initial default values - ResourceMapper.populate( - questionnaire, - launchContexts = launchContextResources.associateBy { it.resourceType.name.lowercase() }, + } + + /** + * This function searches and returns the latest [QuestionnaireResponse] for the given + * [resourceId] that was extracted from the [Questionnaire] identified as [questionnaireId]. + * Returns null if non is found. + */ + suspend fun searchQuestionnaireResponse( + resourceId: String, + resourceType: ResourceType, + questionnaireId: String, + encounterId: String?, + questionnaireResponseStatus: String? = null, + ): QuestionnaireResponse? { + val search = + Search(ResourceType.QuestionnaireResponse).apply { + filter( + QuestionnaireResponse.SUBJECT, + { value = resourceId.asReference(resourceType).reference }, ) - - questionnaire.prepopulateWithComputedConfigValues( - questionnaireConfig, - actionParameters, - { resourceDataRulesExecutor.computeResourceDataRules(it, null, emptyMap()) }, - { uniqueIdAssignmentConfig, computedValues -> - // Extract ID from a Group, should be modified in future to support other resources - uniqueIdResource = - defaultRepository.retrieveUniqueIdAssignmentResource( - uniqueIdAssignmentConfig, - computedValues, - ) - - fhirPathDataExtractor.extractValue( - base = uniqueIdResource, - expression = uniqueIdAssignmentConfig.idFhirPathExpression, - ) - }, + filter( + QuestionnaireResponse.QUESTIONNAIRE, + { value = questionnaireId.asReference(ResourceType.Questionnaire).reference }, ) - - // Populate questionnaire with latest QuestionnaireResponse - val questionnaireResponse = - if ( - resourceType != null && - !resourceIdentifier.isNullOrEmpty() && - (questionnaireConfig.isEditable() || questionnaireConfig.isReadOnly()) - ) { - searchQuestionnaireResponse( - resourceId = resourceIdentifier, - resourceType = resourceType, - questionnaireId = questionnaire.logicalId, - encounterId = questionnaireConfig.encounterId, - ) - ?.let { - QuestionnaireResponse().apply { - item = it.item.removeUnAnsweredItems() - // Clearing the text prompts the SDK to re-process the content, which includes HTML - clearText() - } - } - } else { - null - } - - // Exclude the configured fields from QR - if (questionnaireResponse != null) { - val exclusionLinkIdsMap: Map = - questionnaireConfig.linkIds - ?.asSequence() - ?.filter { it.type == LinkIdType.PREPOPULATION_EXCLUSION } - ?.associateBy { it.linkId } - ?.mapValues { it.value.type == LinkIdType.PREPOPULATION_EXCLUSION } - ?: emptyMap() - - questionnaireResponse.item = - excludePrepopulationFields( - questionnaireResponse.item.toMutableList(), - exclusionLinkIdsMap - ) + if (!encounterId.isNullOrBlank()) { + filter( + QuestionnaireResponse.ENCOUNTER, + { + value = + encounterId.extractLogicalIdUuid().asReference(ResourceType.Encounter).reference + }, + ) } - return Pair(questionnaireResponse, launchContextResources) - } - - fun excludePrepopulationFields( - items: MutableList, - exclusionMap: Map, - ): MutableList { - val stack = LinkedList>() - stack.push(items) - while (stack.isNotEmpty()) { - val currentItems = stack.pop() - val iterator = currentItems.iterator() - while (iterator.hasNext()) { - val item = iterator.next() - if (exclusionMap.containsKey(item.linkId)) { - iterator.remove() - } else if (item.item.isNotEmpty()) { - stack.push(item.item) - } + if (!questionnaireResponseStatus.isNullOrBlank()) { + filter( + QuestionnaireResponse.STATUS, + { value = of(questionnaireResponseStatus) }, + ) + } + } + val questionnaireResponses: List = defaultRepository.search(search) + return questionnaireResponses.maxByOrNull { it.meta.lastUpdated } + } + + private suspend fun launchContextResources( + subjectResourceType: ResourceType?, + subjectResourceIdentifier: String?, + actionParameters: List, + ): List { + return when { + subjectResourceType != null && subjectResourceIdentifier != null -> + mutableListOf().apply { + loadResource(subjectResourceType, subjectResourceIdentifier)?.let { add(it) } + val actionParametersExcludingSubject = + actionParameters.filterNot { + it.paramType == ActionParameterType.QUESTIONNAIRE_RESPONSE_POPULATION_RESOURCE && + subjectResourceType == it.resourceType && + subjectResourceIdentifier.equals(it.value, ignoreCase = true) } + addAll(retrievePopulationResources(actionParametersExcludingSubject)) } - return items - } - - private fun List.removeUnAnsweredItems(): - List { - return this.asSequence() - .filter { it.hasAnswer() || it.item.isNotEmpty() } - .onEach { it.item = it.item.removeUnAnsweredItems() } - .filter { it.hasAnswer() || it.item.isNotEmpty() } - .toList() + else -> retrievePopulationResources(actionParameters) } - - /** - * Return [Resource]s to be used in the launch context of the questionnaire. Launch context allows - * information to be passed into questionnaire based on the context in which the questionnaire is - * being evaluated. For example, what patient, what encounter, what user, etc. is "in context" at - * the time the questionnaire response is being completed: - * https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-launchContext.html - */ - suspend fun retrievePopulationResources(actionParameters: List): List { - return actionParameters - .filter { - it.paramType == ActionParameterType.QUESTIONNAIRE_RESPONSE_POPULATION_RESOURCE && - it.resourceType != null && - it.value.isNotEmpty() - } - .mapNotNull { - try { - loadResource(it.resourceType!!, it.value) - } catch (resourceNotFoundException: ResourceNotFoundException) { - null - } + } + + suspend fun populateQuestionnaire( + questionnaire: Questionnaire, + questionnaireConfig: QuestionnaireConfig, + actionParameters: List, + ): Pair> { + val questionnaireSubjectType = questionnaire.subjectType.firstOrNull()?.code + val resourceType = + questionnaireConfig.resourceType ?: questionnaireSubjectType?.let { ResourceType.valueOf(it) } + val resourceIdentifier = questionnaireConfig.resourceIdentifier + + val launchContextResources = + launchContextResources(resourceType, resourceIdentifier, actionParameters) + + // Populate questionnaire with initial default values + ResourceMapper.populate( + questionnaire, + launchContexts = launchContextResources.associateBy { it.resourceType.name.lowercase() }, + ) + + questionnaire.prepopulateWithComputedConfigValues( + questionnaireConfig, + actionParameters, + { resourceDataRulesExecutor.computeResourceDataRules(it, null, emptyMap()) }, + { uniqueIdAssignmentConfig, computedValues -> + // Extract ID from a Group, should be modified in future to support other resources + uniqueIdResource = + defaultRepository.retrieveUniqueIdAssignmentResource( + uniqueIdAssignmentConfig, + computedValues, + ) + + withContext(dispatcherProvider.default()) { + fhirPathDataExtractor.extractValue( + base = uniqueIdResource, + expression = uniqueIdAssignmentConfig.idFhirPathExpression, + ) + } + }, + ) + + // Populate questionnaire with latest QuestionnaireResponse + val questionnaireResponse = + if ( + resourceType != null && + !resourceIdentifier.isNullOrEmpty() && + (questionnaireConfig.isEditable() || + questionnaireConfig.isReadOnly() || + questionnaireConfig.saveDraft) + ) { + searchQuestionnaireResponse( + resourceId = resourceIdentifier, + resourceType = resourceType, + questionnaireId = questionnaire.logicalId, + encounterId = questionnaireConfig.encounterId, + questionnaireResponseStatus = questionnaireConfig.questionnaireResponseStatus(), + ) + ?.let { + QuestionnaireResponse().apply { + item = it.item.removeUnAnsweredItems() + // Clearing the text prompts the SDK to re-process the content, which includes HTML + clearText() } + } + } else { + null + } + + // Exclude the configured fields from QR + if (questionnaireResponse != null) { + val exclusionLinkIdsMap: Map = + questionnaireConfig.linkIds + ?.asSequence() + ?.filter { it.type == LinkIdType.PREPOPULATION_EXCLUSION } + ?.associateBy { it.linkId } + ?.mapValues { it.value.type == LinkIdType.PREPOPULATION_EXCLUSION } ?: emptyMap() + + questionnaireResponse.item = + excludePrepopulationFields(questionnaireResponse.item.toMutableList(), exclusionLinkIdsMap) } - - /** Load [Resource] of type [ResourceType] for the provided [resourceIdentifier] */ - suspend fun loadResource(resourceType: ResourceType, resourceIdentifier: String): Resource? = - try { - defaultRepository.loadResource(resourceIdentifier, resourceType) - } catch (resourceNotFoundException: ResourceNotFoundException) { - null + return Pair(questionnaireResponse, launchContextResources) + } + + fun excludePrepopulationFields( + items: MutableList, + exclusionMap: Map, + ): MutableList { + val stack = LinkedList>() + stack.push(items) + while (stack.isNotEmpty()) { + val currentItems = stack.pop() + val iterator = currentItems.iterator() + while (iterator.hasNext()) { + val item = iterator.next() + if (exclusionMap.containsKey(item.linkId)) { + iterator.remove() + } else if (item.item.isNotEmpty()) { + stack.push(item.item) } - - /** Update the current progress state of the questionnaire. */ - fun setProgressState(questionnaireState: QuestionnaireProgressState) { - _questionnaireProgressStateLiveData.postValue(questionnaireState) + } } - - companion object { - const val CONTAINED_LIST_TITLE = "GeneratedResourcesList" - const val OUTPUT_PARAMETER_KEY = "OUTPUT" + return items + } + + private fun List.removeUnAnsweredItems(): + List { + return this.asSequence() + .filter { it.hasAnswer() || it.item.isNotEmpty() } + .onEach { it.item = it.item.removeUnAnsweredItems() } + .filter { it.hasAnswer() || it.item.isNotEmpty() } + .toList() + } + + /** + * Return [Resource]s to be used in the launch context of the questionnaire. Launch context allows + * information to be passed into questionnaire based on the context in which the questionnaire is + * being evaluated. For example, what patient, what encounter, what user, etc. is "in context" at + * the time the questionnaire response is being completed: + * https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-launchContext.html + */ + suspend fun retrievePopulationResources(actionParameters: List): List { + return actionParameters + .filter { + it.paramType == ActionParameterType.QUESTIONNAIRE_RESPONSE_POPULATION_RESOURCE && + it.resourceType != null && + it.value.isNotEmpty() + } + .distinctBy { "${it.resourceType?.name}${it.value}" } + .mapNotNull { loadResource(it.resourceType!!, it.value) } + } + + /** Load [Resource] of type [ResourceType] for the provided [resourceIdentifier] */ + suspend fun loadResource(resourceType: ResourceType, resourceIdentifier: String): Resource? = + try { + defaultRepository.loadResource(resourceIdentifier, resourceType) + } catch (resourceNotFoundException: ResourceNotFoundException) { + null } -} \ No newline at end of file + + /** Update the current progress state of the questionnaire. */ + fun setProgressState(questionnaireState: QuestionnaireProgressState) { + _questionnaireProgressStateLiveData.postValue(questionnaireState) + } + + companion object { + const val CONTAINED_LIST_TITLE = "GeneratedResourcesList" + const val OUTPUT_PARAMETER_KEY = "OUTPUT" + } +} 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 9d31df58c93..9e6aff8fb8d 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 @@ -88,16 +88,12 @@ class RegisterFragment : Fragment(), OnSyncListener { savedInstanceState: Bundle?, ): View { with(registerFragmentArgs) { - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { - registerViewModel.retrieveRegisterUiState( - registerId = registerId, - screenTitle = screenTitle, - params = params, - clearCache = false, - ) - } - } + registerViewModel.retrieveRegisterUiState( + registerId = registerId, + screenTitle = screenTitle, + params = params, + clearCache = false, + ) } return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) @@ -174,6 +170,7 @@ class RegisterFragment : Fragment(), OnSyncListener { openDrawer = openDrawer, onEvent = registerViewModel::onEvent, registerUiState = registerViewModel.registerUiState.value, + registerUiCountState = registerViewModel.registerUiCountState.value, appDrawerUIState = appMainViewModel.appDrawerUiState.value, onAppMainEvent = { appMainViewModel.onEvent(it) }, searchQuery = searchViewModel.searchQuery, 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 515739ff588..4db108fa4f7 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 @@ -93,6 +93,7 @@ fun RegisterScreen( openDrawer: (Boolean) -> Unit, onEvent: (RegisterEvent) -> Unit, registerUiState: RegisterUiState, + registerUiCountState: RegisterUiCountState, appDrawerUIState: AppDrawerUIState = AppDrawerUIState(), onAppMainEvent: (AppMainEvent) -> Unit, searchQuery: MutableState, @@ -114,9 +115,10 @@ fun RegisterScreen( registerUiState.registerConfiguration?.topScreenSection?.title ?: "" }, searchQuery = searchQuery.value, - filteredRecordsCount = registerUiState.filteredRecordsCount, + filteredRecordsCount = registerUiCountState.filteredRecordsCount, isSearchBarVisible = registerUiState.registerConfiguration?.searchBar?.visible ?: true, searchPlaceholder = registerUiState.registerConfiguration?.searchBar?.display, + placeholderColor = registerUiState.registerConfiguration?.searchBar?.placeholderColor, showSearchByQrCode = registerUiState.registerConfiguration?.showSearchByQrCode ?: false, toolBarHomeNavigation = toolBarHomeNavigation, onSearchTextChanged = { uiSearchQuery, performSearchOnValueChanged -> @@ -198,6 +200,7 @@ fun RegisterScreen( lazyListState = lazyListState, onEvent = onEvent, registerUiState = registerUiState, + registerUiCountState = registerUiCountState, currentPage = currentPage, showPagination = !registerUiState.registerConfiguration.infiniteScroll && @@ -292,13 +295,17 @@ fun RegisterScreenWithDataPreview() { FhirResourceConfig(baseResource = ResourceConfig(resource = ResourceType.Patient)), ), registerId = "register101", - totalRecordsCount = 1, - filteredRecordsCount = 0, - pagesCount = 1, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), params = emptyList(), ) + + val registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 1, + ) val searchText = remember { mutableStateOf(SearchQuery.emptyText) } val currentPage = remember { mutableIntStateOf(0) } val data = listOf(ResourceData("1", ResourceType.Patient, emptyMap())) @@ -310,6 +317,7 @@ fun RegisterScreenWithDataPreview() { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = registerUiCountState, onAppMainEvent = {}, searchQuery = searchText, currentPage = currentPage, diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiCountState.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiCountState.kt new file mode 100644 index 00000000000..d319d0de2c1 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/RegisterUiCountState.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2021-2024 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 + +data class RegisterUiCountState( + val totalRecordsCount: Long = 0, + val filteredRecordsCount: Long? = null, + val pagesCount: Int = 1, +) 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 49698fa760a..eabfe3e00d7 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 @@ -27,9 +27,6 @@ data class RegisterUiState( val isFirstTimeSync: Boolean = false, 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), val currentSyncJobStatus: Flow = flowOf(null), 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 3d8aca437c7..ec82084b82b 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 @@ -73,15 +77,18 @@ 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.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 @@ -90,11 +97,13 @@ constructor( val configurationRegistry: ConfigurationRegistry, val sharedPreferencesHelper: SharedPreferencesHelper, val resourceDataRulesExecutor: ResourceDataRulesExecutor, + val dispatcherProvider: DispatcherProvider, ) : ViewModel() { private val _snackBarStateFlow = MutableSharedFlow() val snackBarStateFlow = _snackBarStateFlow.asSharedFlow() val registerUiState = mutableStateOf(RegisterUiState()) + val registerUiCountState = mutableStateOf(RegisterUiCountState()) val currentPage: MutableState = mutableIntStateOf(0) val registerData: MutableStateFlow>> = MutableStateFlow(emptyFlow()) val pagesDataCache = mutableMapOf>>() @@ -112,6 +121,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 +223,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 +237,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 @@ -622,12 +659,18 @@ constructor( ) { if (registerId.isNotEmpty()) { val paramsMap: Map = params.toParamDataMap() - viewModelScope.launch { - val currentRegisterConfiguration = retrieveRegisterConfiguration(registerId, paramsMap) - if (currentRegisterConfiguration.infiniteScroll) { - registerData.value = - retrieveCompleteRegisterData(currentRegisterConfiguration.id, clearCache) - } else { + + val currentRegisterConfiguration = retrieveRegisterConfiguration(registerId, paramsMap) + if (currentRegisterConfiguration.infiniteScroll) { + registerData.value = + retrieveCompleteRegisterData(currentRegisterConfiguration.id, clearCache) + } else { + paginateRegisterData( + registerId = registerId, + loadAll = false, + clearCache = clearCache, + ) + viewModelScope.launch(dispatcherProvider.io()) { _totalRecordsCount.longValue = registerRepository.countRegisterData( registerId = registerId, @@ -643,49 +686,50 @@ constructor( fhirResourceConfig = registerFilterState.value.fhirResourceConfig, ) } - paginateRegisterData( - registerId = registerId, - loadAll = false, - clearCache = clearCache, - ) - } - registerUiState.value = - RegisterUiState( - screenTitle = currentRegisterConfiguration.registerTitle ?: screenTitle, - isFirstTimeSync = - sharedPreferencesHelper - .read( - SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, - null, - ) - .isNullOrEmpty() && - _totalRecordsCount.longValue == 0L && - applicationConfiguration.usePractitionerAssignedLocationOnSync, - registerConfiguration = currentRegisterConfiguration, - registerId = registerId, - totalRecordsCount = _totalRecordsCount.longValue, - filteredRecordsCount = _filteredRecordsCount.longValue, - pagesCount = - ceil( - (if (registerFilterState.value.fhirResourceConfig != null) { - _filteredRecordsCount.longValue - } else { - _totalRecordsCount.longValue - }) - .toDouble() - .div(currentRegisterConfiguration.pageSize.toLong()), - ) - .toInt(), - progressPercentage = _percentageProgress, - isSyncUpload = _isUploadSync, - currentSyncJobStatus = _currentSyncJobStatusFlow, - params = params?.toList() ?: emptyList(), - ) + registerUiCountState.value = + RegisterUiCountState( + totalRecordsCount = _totalRecordsCount.longValue, + filteredRecordsCount = _filteredRecordsCount.longValue, + pagesCount = + ceil( + (if (registerFilterState.value.fhirResourceConfig != null) { + _filteredRecordsCount.longValue + } else { + _totalRecordsCount.longValue + }) + .toDouble() + .div(currentRegisterConfiguration.pageSize.toLong()), + ) + .toInt(), + ) + } } + + registerUiState.value = + RegisterUiState( + screenTitle = currentRegisterConfiguration.registerTitle ?: screenTitle, + isFirstTimeSync = isFirstTimeSync(), + registerConfiguration = currentRegisterConfiguration, + registerId = registerId, + progressPercentage = _percentageProgress, + isSyncUpload = _isUploadSync, + currentSyncJobStatus = _currentSyncJobStatusFlow, + params = params?.toList() ?: emptyList(), + ) } } + private fun isFirstTimeSync() = + sharedPreferencesHelper + .read( + SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, + null, + ) + .isNullOrEmpty() && + applicationConfiguration.usePractitionerAssignedLocationOnSync && + _totalRecordsCount.longValue == 0L + suspend fun emitSnackBarState(snackBarMessageConfig: SnackBarMessageConfig) { _snackBarStateFlow.emit(snackBarMessageConfig) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/components/RegisterCardList.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/components/RegisterCardList.kt index 4121a7af8d1..a78c0658979 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/components/RegisterCardList.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/register/components/RegisterCardList.kt @@ -41,6 +41,7 @@ import org.smartregister.fhircore.engine.ui.components.ErrorMessage import org.smartregister.fhircore.engine.ui.components.register.RegisterFooter import org.smartregister.fhircore.engine.ui.theme.DividerColor import org.smartregister.fhircore.quest.ui.register.RegisterEvent +import org.smartregister.fhircore.quest.ui.register.RegisterUiCountState import org.smartregister.fhircore.quest.ui.register.RegisterUiState import org.smartregister.fhircore.quest.ui.shared.components.ViewRenderer import timber.log.Timber @@ -62,6 +63,7 @@ fun RegisterCardList( lazyListState: LazyListState, onEvent: (RegisterEvent) -> Unit, registerUiState: RegisterUiState, + registerUiCountState: RegisterUiCountState, currentPage: MutableState, showPagination: Boolean = false, onSearchByQrSingleResultAction: (ResourceData) -> Unit, @@ -132,7 +134,7 @@ fun RegisterCardList( RegisterFooter( resultCount = pagingItems.itemCount, currentPage = currentPage.value.plus(1), - pagesCount = registerUiState.pagesCount, + pagesCount = registerUiCountState.pagesCount, previousButtonClickListener = { onEvent(RegisterEvent.MoveToPreviousPage) }, nextButtonClickListener = { onEvent(RegisterEvent.MoveToNextPage) }, ) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/CompoundText.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/CompoundText.kt index 37f546db932..dbe547f334f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/CompoundText.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/shared/components/CompoundText.kt @@ -97,6 +97,7 @@ fun CompoundText( navController = navController, overflow = compoundTextProperties.overflow, letterSpacing = compoundTextProperties.letterSpacing, + textInnerPadding = compoundTextProperties.textInnerPadding, ) } // Separate the primary and secondary text @@ -128,6 +129,7 @@ fun CompoundText( resourceData = resourceData, overflow = compoundTextProperties.overflow, letterSpacing = compoundTextProperties.letterSpacing, + textInnerPadding = compoundTextProperties.textInnerPadding, ) } } @@ -153,6 +155,7 @@ private fun CompoundTextPart( resourceData: ResourceData, overflow: TextOverFlow?, letterSpacing: Int = 0, + textInnerPadding: Int = 0, ) { Text( text = @@ -175,7 +178,7 @@ private fun CompoundTextPart( ) .clip(RoundedCornerShape(borderRadius.dp)) .background(backgroundColor.parseColor()) - .padding(0.dp), + .padding(textInnerPadding.dp), fontSize = fontSize.sp, fontWeight = textFontWeight.fontWeight, textAlign = diff --git a/android/quest/src/main/res/drawable/ic_qr_code.xml b/android/quest/src/main/res/drawable/ic_qr_code.xml index e08ef94df7d..519f19de697 100644 --- a/android/quest/src/main/res/drawable/ic_qr_code.xml +++ b/android/quest/src/main/res/drawable/ic_qr_code.xml @@ -1,10 +1,13 @@ + android:viewportWidth="20" + android:viewportHeight="20"> + + android:fillAlpha="0.25"/> diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt index 578a91120ff..bac9f9b7889 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModelTest.kt @@ -19,7 +19,6 @@ package org.smartregister.fhircore.quest.ui.questionnaire import android.app.Application import androidx.test.core.app.ApplicationProvider import ca.uhn.fhir.parser.IParser -import ca.uhn.fhir.validation.FhirValidator import com.google.android.fhir.FhirEngine import com.google.android.fhir.SearchResult import com.google.android.fhir.datacapture.extensions.logicalId @@ -29,6 +28,7 @@ import com.google.android.fhir.get import com.google.android.fhir.knowledge.KnowledgeManager import com.google.android.fhir.search.Search import com.google.android.fhir.workflow.FhirOperator +import dagger.Lazy import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.coEvery @@ -46,7 +46,6 @@ import java.io.File import java.util.Date import java.util.UUID import javax.inject.Inject -import javax.inject.Provider import kotlin.test.assertEquals import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -57,6 +56,7 @@ import org.hl7.fhir.r4.model.Attachment import org.hl7.fhir.r4.model.Basic import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.CarePlan import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Consent @@ -77,6 +77,7 @@ import org.hl7.fhir.r4.model.Parameters import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseStatus import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -111,9 +112,11 @@ import org.smartregister.fhircore.engine.util.extension.encodeResourceToString import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import org.smartregister.fhircore.engine.util.extension.find import org.smartregister.fhircore.engine.util.extension.isToday +import org.smartregister.fhircore.engine.util.extension.questionnaireResponseStatus import org.smartregister.fhircore.engine.util.extension.valueToString import org.smartregister.fhircore.engine.util.extension.yesterday import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor +import org.smartregister.fhircore.engine.util.validation.ResourceValidationRequestHandler import org.smartregister.fhircore.quest.app.fakes.Faker import org.smartregister.fhircore.quest.assertResourceEquals import org.smartregister.fhircore.quest.robolectric.RobolectricTest @@ -129,7 +132,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper - @Inject lateinit var fhirValidatorProvider: Provider + @Inject lateinit var fhirValidatorRequestHandlerProvider: Lazy @Inject lateinit var configService: ConfigService @@ -210,7 +213,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { resourceDataRulesExecutor = resourceDataRulesExecutor, transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, - fhirValidatorProvider = fhirValidatorProvider, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, fhirOperator = fhirOperator, fhirPathDataExtractor = fhirPathDataExtractor, configurationRegistry = configurationRegistry, @@ -650,7 +653,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, fhirOperator = fhirOperator, - fhirValidatorProvider = fhirValidatorProvider, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, fhirPathDataExtractor = fhirPathDataExtractor, configurationRegistry = configurationRegistry, ) @@ -734,7 +737,10 @@ class QuestionnaireViewModelTest : RobolectricTest() { }, ) } - questionnaireViewModel.saveDraftQuestionnaire(questionnaireResponse) + questionnaireViewModel.saveDraftQuestionnaire( + questionnaireResponse, + QuestionnaireConfig("qr-id-1"), + ) Assert.assertEquals( QuestionnaireResponse.QuestionnaireResponseStatus.INPROGRESS, questionnaireResponse.status, @@ -742,6 +748,69 @@ class QuestionnaireViewModelTest : RobolectricTest() { coVerify { defaultRepository.addOrUpdate(resource = questionnaireResponse) } } + @Test + fun testSaveDraftQuestionnaireShouldUpdateSubjectAndQuestionnaireValues() = runTest { + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() + .setValue(StringType("Sky is the limit")), + ) + }, + ) + } + questionnaireViewModel.saveDraftQuestionnaire( + questionnaireResponse, + QuestionnaireConfig( + "dc-household-registration", + resourceIdentifier = "group-id-1", + resourceType = ResourceType.Group, + ), + ) + Assert.assertEquals( + "Questionnaire/dc-household-registration", + questionnaireResponse.questionnaire, + ) + Assert.assertEquals( + "Group/group-id-1", + questionnaireResponse.subject.reference, + ) + coVerify { defaultRepository.addOrUpdate(resource = questionnaireResponse) } + } + + @Test + fun testSaveDraftQuestionnaireCallsAddOrUpdateForPaginatedForms() = runTest { + val pageItem = + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() + .setValue(StringType("Sky is the limit")), + ) + }, + ) + } + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem( + pageItem, + ) + } + questionnaireViewModel.saveDraftQuestionnaire( + questionnaireResponse, + QuestionnaireConfig( + "dc-household-registration", + resourceIdentifier = "group-id-1", + resourceType = ResourceType.Group, + ), + ) + + coVerify { defaultRepository.addOrUpdate(resource = questionnaireResponse) } + } + @Test fun testUpdateResourcesLastUpdatedProperty() = runTest { val yesterday = yesterday() @@ -1103,6 +1172,9 @@ class QuestionnaireViewModelTest : RobolectricTest() { fun testGenerateCarePlan() = runTest { val bundle = Bundle().apply { addEntry(Bundle.BundleEntryComponent().apply { resource = patient }) } + coEvery { + fhirCarePlanGenerator.generateOrUpdateCarePlan(any(), any(), any(), any()) + } returns CarePlan() val questionnaireConfig = questionnaireConfig.copy(planDefinitions = listOf("planDefId")) questionnaireViewModel.generateCarePlan(patient, bundle, questionnaireConfig) @@ -1292,6 +1364,56 @@ class QuestionnaireViewModelTest : RobolectricTest() { Assert.assertEquals("QuestionnaireResponse/qr1", latestQuestionnaireResponse?.id) } + @Test + fun testSearchLatestQuestionnaireResponseWhenSaveDraftIsTueShouldReturnLatestQuestionnaireResponse() = + runTest(timeout = 90.seconds) { + Assert.assertNull( + questionnaireViewModel.searchQuestionnaireResponse( + resourceId = patient.logicalId, + resourceType = ResourceType.Patient, + questionnaireId = questionnaireConfig.id, + encounterId = null, + questionnaireResponseStatus = QuestionnaireResponseStatus.INPROGRESS.toCode(), + ), + ) + + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "qr1" + meta.lastUpdated = Date() + subject = patient.asReference() + questionnaire = samplePatientRegisterQuestionnaire.asReference().reference + status = QuestionnaireResponseStatus.INPROGRESS + }, + QuestionnaireResponse().apply { + id = "qr2" + meta.lastUpdated = yesterday() + subject = patient.asReference() + questionnaire = samplePatientRegisterQuestionnaire.asReference().reference + status = QuestionnaireResponseStatus.COMPLETED + }, + ) + + // Add QuestionnaireResponse to database + fhirEngine.create( + patient, + samplePatientRegisterQuestionnaire, + *questionnaireResponses.toTypedArray(), + ) + + val latestQuestionnaireResponse = + questionnaireViewModel.searchQuestionnaireResponse( + resourceId = patient.logicalId, + resourceType = ResourceType.Patient, + questionnaireId = questionnaireConfig.id, + encounterId = null, + questionnaireResponseStatus = QuestionnaireResponseStatus.INPROGRESS.toCode(), + ) + Assert.assertNotNull(latestQuestionnaireResponse) + Assert.assertEquals("QuestionnaireResponse/qr1", latestQuestionnaireResponse?.id) + } + @Test fun testRetrievePopulationResourcesReturnsListOfResourcesOrEmptyList() = runTest { val specimenId = "specimenId" @@ -1445,6 +1567,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { } listResource.addEntry(listEntryComponent) addContained(listResource) + status = QuestionnaireResponse.QuestionnaireResponseStatus.COMPLETED } coEvery { @@ -1453,6 +1576,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { resourceType = ResourceType.Patient, questionnaireId = questionnaireConfig.id, encounterId = null, + questionnaireResponseStatus = questionnaireConfig.questionnaireResponseStatus(), ) } returns previousQuestionnaireResponse @@ -1829,7 +1953,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, fhirOperator = fhirOperator, - fhirValidatorProvider = fhirValidatorProvider, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, fhirPathDataExtractor = fhirPathDataExtractor, configurationRegistry = configurationRegistry, ) @@ -1891,7 +2015,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, fhirOperator = fhirOperator, - fhirValidatorProvider = fhirValidatorProvider, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, fhirPathDataExtractor = fhirPathDataExtractor, configurationRegistry = configurationRegistry, ) @@ -1966,7 +2090,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, fhirOperator = fhirOperator, - fhirValidatorProvider = fhirValidatorProvider, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, fhirPathDataExtractor = fhirPathDataExtractor, configurationRegistry = configurationRegistry, ) 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 66775f2953f..a4a903b17e3 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 @@ -51,6 +51,7 @@ import org.smartregister.fhircore.engine.domain.model.ActionConfig import org.smartregister.fhircore.engine.domain.model.ResourceData import org.smartregister.fhircore.engine.domain.model.SnackBarMessageConfig import org.smartregister.fhircore.engine.domain.model.ToolBarHomeNavigation +import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.quest.app.fakes.Faker import org.smartregister.fhircore.quest.event.EventBus import org.smartregister.fhircore.quest.navigation.NavigationArg @@ -66,6 +67,8 @@ class RegisterFragmentTest : RobolectricTest() { @Inject lateinit var eventBus: EventBus + @Inject lateinit var dispatcherProvider: DispatcherProvider + @BindValue val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() @@ -87,6 +90,7 @@ class RegisterFragmentTest : RobolectricTest() { configurationRegistry = configurationRegistry, sharedPreferencesHelper = Faker.buildSharedPreferencesHelper(), resourceDataRulesExecutor = mockk(), + dispatcherProvider = dispatcherProvider, ), ) registerFragmentMock = mockk() 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 3746f047c96..8f7e92738fc 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 @@ -55,6 +59,7 @@ 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.DispatcherProvider import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.quest.app.fakes.Faker @@ -67,6 +72,8 @@ class RegisterViewModelTest : RobolectricTest() { @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + @Inject lateinit var dispatcherProvider: DispatcherProvider + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() private lateinit var registerViewModel: RegisterViewModel private lateinit var registerRepository: RegisterRepository @@ -87,6 +94,7 @@ class RegisterViewModelTest : RobolectricTest() { configurationRegistry = configurationRegistry, sharedPreferencesHelper = sharedPreferencesHelper, resourceDataRulesExecutor = resourceDataRulesExecutor, + dispatcherProvider = dispatcherProvider, ), ) @@ -114,7 +122,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 +130,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( @@ -138,13 +150,12 @@ class RegisterViewModelTest : RobolectricTest() { val registerConfiguration = registerUiState.registerConfiguration Assert.assertNotNull(registerConfiguration) Assert.assertEquals("app", registerConfiguration?.appId) - Assert.assertEquals(200, registerUiState.totalRecordsCount) - Assert.assertEquals(20, registerUiState.pagesCount) } @Test - fun testOnEventSearchRegister() { - every { registerViewModel.retrieveRegisterConfiguration(any()) } returns + @kotlinx.coroutines.ExperimentalCoroutinesApi + fun testDebounceSearchQueryFlow() = runTest { + val registerConfig = RegisterConfiguration( appId = "app", id = registerId, @@ -152,14 +163,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()) } } diff --git a/docs/engineering/admin-dashboard/readme.mdx b/docs/engineering/admin-dashboard/readme.mdx index b30b11a8947..fb432e39693 100644 --- a/docs/engineering/admin-dashboard/readme.mdx +++ b/docs/engineering/admin-dashboard/readme.mdx @@ -1,3 +1,19 @@ # Admin Dashboard -We use [fhir-web](https://github.com/onaio/fhir-web) as the administrative dashboard for OpenSRP 2 projects. +We use [fhir-web](https://github.com/onaio/fhir-web) as the admin dashboard for OpenSRP 2 projects. This allows you to manage users, teams and view data. See more details on the [admin dashboard features](/features/admin-dashboard-features). + +## Users and the FHIR Practitioner resource + +OpenSRP2 manages user accounts through an integrated identity and authentication management (IAM) system. Each `user` created in the IAM system has a 1-to-1 link with FHIR `Practitioner` resource in the FHIR health data store. + +## User Assignment + +User assignment consists of 4 main elements +- **User (FHIR Practitioner)** - This is the person using the application to collect data and execute workflows +- **CareTeam** - This is used to define a group of users that provide care as a team. +- **Team (FHIR Organization)** - Teams are added to CareTeams as the `.managingOrganization`. +- **Location** - Teams are assigned to Locations to define where they work. + +Every user must have at least 1 of the 4 elements above. These assignments affect how data is [synced down](/engineering/backend/info-gateway/data-sync) to the app. + +These elements are then downloaded to the app via the **PractitionerDetail** endpoint. This is a custom endpoint that aggregates data related to a user and returns as a single response. \ No newline at end of file diff --git a/docs/engineering/app/configuring/forms/save-form-as-draft.mdx b/docs/engineering/app/configuring/forms/save-form-as-draft.mdx new file mode 100644 index 00000000000..c9d33123c45 --- /dev/null +++ b/docs/engineering/app/configuring/forms/save-form-as-draft.mdx @@ -0,0 +1,55 @@ +--- +title: Save form as draft +--- + +This is support for saving a form, that is in progress, as a draft. + +This functionality is only relevant to forms that are unique and only expected to be filled out once. +This excludes forms such as "register client" or "register household". + +## Sample use cases + +- A counseling session cannot be completed in one sitting, so the counselor would like to save the incomplete session and continue it in the next session +- A health care worker does not have the answer to a mandatory question (e.g. lab results) and cannot submit the form until it is answered; they also do not want to discard the data they have already entered +- A patient meets with multiple providers during a clinic visit. They would like the ability for the form to be started by one worker and completed by another worker +- A health care worker is doing a child visit and the mother goes to get the child's health card to update the immunization history. Meanwhile, the health care worker wants to proceed to measure the child's MUAC (which is collected in a different form) +- A health care worker is doing a household visit and providing care to multiple household members. They want the ability to start a workflow and switch to another workflow without losing their data +- A health care worker is required to collect data in both the app and on paper. They start a form in the app, but are under time pressure, so they fill out the paper form and plan to enter the data in the app later + + +The configuration is done on the `QuestionnaireConfig`. +The sample below demonstrates the configs that are required in order to save a form as a draft + +```json +{ + "questionnaire": { + "id": "add-family-member", + "title": "Add Family Member", + "resourceIdentifier": "sample-house-id", + "resourceType": "Group", + "saveDraft": true + } +} +``` +## Config properties + +|Property | Description | Required | Default | +|--|--|:--:|:--:| +id | Questionnaire Unique ID String | yes | | +title | Display text shown when the form is loaded | no | | +resourceIdentifier | Unique ID String for the subject of the form | | | +resourceType | The String representation of the resource type for the subject of the form | yes | | +saveDraft | Flag that determines whether the form can be saved as a draft | yes | false | + +## UI/UX workflow +When the form is opened, with the configurations in place, the save as draft functionality is triggered when the user clicks on the close button (X) at the top left of the screen. +A dialog appears with 3 buttons i.e `Save as draft`, `Discard changes` and `Cancel`. + +The table below details what each of the buttons does. + +### Alert dialog buttons descriptions +|Button | Description | +|--|--|:--:|:--:| +Save as draft | Saves user input as a draft | +Discard changes | Dismisses user input, and closes the form without saving the draft. | +Cancel | Dismisses the dialog so that the user can continue interacting with the form | \ No newline at end of file diff --git a/docs/engineering/app/datastore/tagging.mdx b/docs/engineering/app/datastore/tagging.mdx index 0690ca00f6a..18c446dc6b4 100644 --- a/docs/engineering/app/datastore/tagging.mdx +++ b/docs/engineering/app/datastore/tagging.mdx @@ -19,7 +19,7 @@ System Suffix|Display|Purpose `practitioner-tag-id`|Practitioner|This is the Practitioner that is logged into the app when the resource is created. `location-tag-id`|Practitioner Location|This is the Location linked to the Organization of the Practitioner that is logged into the app when the resource is created. `organisation-tag-id`|Practitioner Organization|This is the Organization linked to the Practitioner that is logged into the app when the resource is created. -`related-entity-location-tag-id`|Related Entity Location|"Entity" here is a `Patient`, `Group`, Point of Service (as a `Location` resource), or other organizing unit, and this stores the ID of a `Location` resource (or the resource itself if it is a `Location`) lnked to that entity. +`related-entity-location-tag-id`|Related Entity Location|"Entity" here is a `Patient`, `Group`, Point of Service (as a `Location` resource), or other organizing unit, and this stores the ID of a `Location` resource (or the resource itself if it is a `Location`) linked to that entity. ## Example Tags diff --git a/docs/engineering/backend/info-gateway.mdx b/docs/engineering/backend/info-gateway/readme.mdx similarity index 100% rename from docs/engineering/backend/info-gateway.mdx rename to docs/engineering/backend/info-gateway/readme.mdx diff --git a/docs/engineering/backend/info-gateway/sync-strategies.mdx b/docs/engineering/backend/info-gateway/sync-strategies.mdx new file mode 100644 index 00000000000..d0151cc0230 --- /dev/null +++ b/docs/engineering/backend/info-gateway/sync-strategies.mdx @@ -0,0 +1,57 @@ +--- +title: Sync Strategies +--- + +OpenSRP 2 uses five key data elements in determining how data is synced down from the server. These elements are [added](/engineering/app/datastore/tagging) to every resource created by the OpenSRP mobile app, enabling precise synchronization. These elements are: + +- `care-team-tag-id` +- `practitioner-tag-id` +- `location-tag-id` +- `organisation-tag-id` +- `related-entity-location-tag-id` + +### Sync by Practitioner CareTeam +This strategy syncs data based on the CareTeam that the logged in user (which maps 1-to-1 to a FHIR Practitioner Resource) is assigned to. All resources tagged with the same CareTeam via the `care-team-tag-id` are synced down to the device if the FHIR Practitioner mapping to the logged-in user is assigned to that CareTeam. A sample tag is provided below + +```json + { + "system": "https://smartregister.org/care-team-tag-id", + "code": "47d68cac-306f-4b75-9704-b4ed48b24f76", + "display": "Practitioner CareTeam" + } +``` + +### Sync by Practitioner Team (FHIR Organization) +This sync strategy is based on the team (FHIR Organization) and syncs resources tied to the specific team (FHIR Organization) associated with the logged user's FHIR Practitioner. + +- This sync strategy also includes data from any CareTeams that have the Organization as a [managing organization](https://hl7.org/fhir/R4B/careteam-definitions.html#CareTeam.managingOrganization). A sample tag is provided below + +```json + { + "system": "https://smartregister.org/organisation-tag-id", + "code": "ca7d3362-8048-4fa0-8fdd-6da33423cc6b", + "display": "Practitioner Organization" + } +``` +### Sync by Practitioner Location +This sync strategy is based on the FHIR Location and delivers resources tagged with the Location ID of the Location that the logged in user's FHIR Practitioner is assigned to. +- This sync strategy also includes data from all the subordinant locations of the Location that the Practitioner is assigned to (ie if `Location B.partOf = Location A` and we are syncing data from `Location A`, any data assigned to `Location B` is also included). A sample tag is provided below + +```json + { + "system": "https://smartregister.org/location-tag-id", + "code": "ca7d3362-8048-4fa0-8fdd-6da33423cc6b", + "display": "Practitioner Location" + } +``` +### Sync by Related Entity Location +This strategy uses location information related to other entities (e.g Patient, Family / Group, Service Point), ensuring that data linked to specific locations associated with those entities is synced. +- This sync strategy also includes data from all the child locations linked to the Related Entity Location. A sample tag is provided below + +```json + { + "system": "https://smartregister.org/related-entity-location-tag-id", + "code": "33f45e09-f96e-41d3-9916-fb96455a4cb2", + "display": "Related Entity Location" + } +``` diff --git a/docs/features/admin-dashboard-features/readme.mdx b/docs/features/admin-dashboard-features/readme.mdx index 0c18e1857c7..8b3662fc9a1 100644 --- a/docs/features/admin-dashboard-features/readme.mdx +++ b/docs/features/admin-dashboard-features/readme.mdx @@ -7,7 +7,7 @@ sidebar_label: Admin Dashboard Features This guide outlines the steps to follow to manage Users, Care Team, Organization and Locations on OpenSRP. The platform is named FHIR Web and interacts with the Keycloak and HAPI FHIR servers. -### Prerequisites +## Prerequisites To navigate through this user guide, you are required to have admin access to the admin portal. In addition, you will need to understand how the different entities tie to each other. Below is a summary. A user represents anyone who will interact with the FHIR SDK-based App or FHIR Web. A user is assigned permission through roles defined in Keycloak which maintains all user authentication. Keycloak defines permissions in the form of roles that can be assigned to a user based on the activities they are expected to carry out. Due to the long list of roles, FHIR Web presents the option to create user groups, which represent a collection of roles that can be assigned to a user. @@ -22,7 +22,7 @@ The steps laid out below ensure a user is able to interact with the 6. Assign the created careteam to the respective Organization 7. Assign Users to the careteam -### Access to Admin portal (OpenSRP 2 web portal) +### Access to Admin portal 1. Access the OpenSRP 2 web portal through the url: a. Production - https://web.ProjectName-production.smartregister.org/ @@ -44,7 +44,7 @@ The steps laid out below ensure a user is able to interact with the 6. Patients Management: helps the users to view the patient registered on App and synced to the server. 7. Main landing page: has shortcuts to the side menu items. -### User Management +## User Management The OpenSRP web provides the ability to create users, user groups and assign users to user groups. User groups allows categorization of users with common user roles, by default there are two user groups; Provider user group which refers to clinicians whose main role is to collect immunization data on the mobile application while the Super User group refers to users who have all the privileges such as managing users, teams and locations. ![user-management](/img/admin_dashboard_features/2.jpg) @@ -107,7 +107,7 @@ Fields with Asterisks are a must to fill. 10. Click ‘set password’ -### How to update User’s detail +### How to update user’s detail 1. Click on the ‘User management’ menu item or on the main landing page 2. This will load a page with a list of existing Users. @@ -122,7 +122,7 @@ Fields with Asterisks are a must to fill. 5. Click ‘Save. -### Location Management +## Location Management This piece of functionality helps the users create locations and add any parent locations for each location. This package is based on the location resource in FHIR. A location hierarchy is defined by assigning parent locations. If a parent is not selected then the location is taken as a root location in the hierarchy. ### How to create a Location unit @@ -146,14 +146,14 @@ You can access this page by clicking on the location management menu on the side 3. Click ‘Save. -### Care Team Management +## Care Team Management This piece of functionality helps the users create CareTeam to be used to administer the different services in the FHIR Core apps. 1. This package also allows the different practitioners to be added to the CareTeam 2. This package also allows the different groups to be added to the CareTeam -### How to update a Location unit -You can access this page by clicking on the CareTeam management menu on the sidebar. This will load a page with a list of existing CareTeam. Below is a sample of how the page would look like. +### How to update a Care Team unit +You can access this page by clicking on the Care Team management menu on the sidebar. This will load a page with a list of existing CareTeam. Below is a sample of how the page would look like. ![fhir-care-team](/img/admin_dashboard_features/16.jpg) @@ -166,7 +166,7 @@ You can access this page by clicking on the CareTeam management menu on the side 4. To assign a Group to that particular CareTeam, click on the ‘Subject’ field and select the groups. 5. Click ‘Save'. -### How to update CareTeam’s detail +### How to update Care Team’s detail You can access this page by clicking on the CareTeam management menu on the sidebar. This will load a page with a list of existing CareTeam. Below is a sample of how the page would look like. ![update-care-team](/img/admin_dashboard_features/18.jpg) @@ -178,7 +178,7 @@ You can access this page by clicking on the CareTeam management menu on the side 2. This will load a form that allows you to update the CareTeam’s details. Fields with Asterisks are a must to fill. 3. Click ‘Save. -### Team Management +## Team Management The FHIR web team(organization) management package provides the ability to perform the following functions. 1. Creating teams (organization). a. Assign users to teams (organization).