diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ac130fc90..ea49da6400 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -272,7 +272,7 @@ jobs: force-avd-creation: true emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true - script: ./gradlew clean -PlocalPropertiesFile=local.properties :quest:fhircoreJacocoReport --stacktrace -Pandroid.testInstrumentationRunnerArguments.notPackage=org.smartregister.fhircore.quest.performance + script: ./gradlew clean -PlocalPropertiesFile=local.properties :quest:fhircoreJacocoReport --info -Pandroid.testInstrumentationRunnerArguments.notPackage=org.smartregister.fhircore.quest.performance - name: Run Quest module unit and instrumentation tests and generate aggregated coverage report (Disabled) if: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 427f2bae60..6b6de70910 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 7c07594ee6..301d5cd199 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 6fd3ab17a4..db5c35d8a9 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 4dc64dd3e8..43ba2ab4a1 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 a81b7a3b24..928a67a918 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/data/local/ContentCache.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/ContentCache.kt new file mode 100644 index 0000000000..ebe30aab94 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/ContentCache.kt @@ -0,0 +1,49 @@ +/* + * 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.data.local + +import androidx.collection.LruCache +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType +import org.smartregister.fhircore.engine.util.DispatcherProvider + +@Singleton +class ContentCache @Inject constructor(private val dispatcherProvider: DispatcherProvider) { + private val maxMemory: Int = (Runtime.getRuntime().maxMemory() / 1024).toInt() + private val cacheSize: Int = maxMemory / 8 + private val cache = LruCache(cacheSize) + private val mutex = Mutex() + + suspend fun saveResource(resource: T): T { + val key = "${resource.resourceType.name}/${resource.idPart}" + return withContext(dispatcherProvider.io()) { + mutex.withLock { cache.put(key, resource.copy()) } + @Suppress("UNCHECKED_CAST") + getResource(resource.resourceType, resource.idPart)!! as T + } + } + + fun getResource(type: ResourceType, id: String) = cache["$type/$id"]?.copy() + + suspend fun invalidate() = + withContext(dispatcherProvider.io()) { mutex.withLock { cache.evictAll() } } +} diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt index 9bad212293..d4e4b069bc 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt @@ -30,6 +30,7 @@ import com.google.android.fhir.SearchResult import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.db.ResourceNotFoundException import com.google.android.fhir.get +import com.google.android.fhir.getResourceType import com.google.android.fhir.search.Order import com.google.android.fhir.search.Search import com.google.android.fhir.search.filter.ReferenceParamFilterCriterion @@ -115,17 +116,33 @@ constructor( @ApplicationContext open val context: Context, ) { + @Inject lateinit var contentCache: ContentCache + + init { + DaggerDefaultRepositoryComponent.create().inject(this) + } + suspend inline fun loadResource(resourceId: String): T? = fhirEngine.loadResource(resourceId) + @Throws(ResourceNotFoundException::class) suspend fun loadResource(resourceId: String, resourceType: ResourceType): Resource = fhirEngine.get(resourceType, resourceId) + @Throws(ResourceNotFoundException::class) suspend fun loadResource(reference: Reference) = IdType(reference.reference).let { fhirEngine.get(ResourceType.fromCode(it.resourceType), it.idPart) } + suspend inline fun loadResourceFromCache(resourceId: String): T? { + val resourceType = getResourceType(T::class.java) + val resource = + contentCache.getResource(resourceType, resourceId) + ?: fhirEngine.loadResource(resourceId)?.let { contentCache.saveResource(it) } + return resource as? T + } + suspend inline fun searchResourceFor( token: TokenClientParam, subjectType: ResourceType, diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryComponent.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryComponent.kt new file mode 100644 index 0000000000..bf93f30cbd --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryComponent.kt @@ -0,0 +1,27 @@ +/* + * 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.data.local + +import dagger.Component +import javax.inject.Singleton +import org.smartregister.fhircore.engine.di.DispatcherModule + +@Singleton +@Component(modules = [DispatcherModule::class]) +interface DefaultRepositoryComponent { + fun inject(defaultRepository: DefaultRepository) +} 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 53af79a8a9..4e49ded6ba 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/task/FhirCarePlanGenerator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCarePlanGenerator.kt index 55c2df7da2..f00e2be3de 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCarePlanGenerator.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/task/FhirCarePlanGenerator.kt @@ -214,8 +214,9 @@ constructor( } source.setParameter(Task.SP_PERIOD, period) source.setParameter(ActivityDefinition.SP_VERSION, IntegerType(index)) + val structureMapId = IdType(action.transform).idPart + val structureMap = defaultRepository.loadResourceFromCache(structureMapId) - val structureMap = fhirEngine.get(IdType(action.transform).idPart) structureMapUtilities.transform( transformSupportServices.simpleWorkerContext, source, 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 b329d1a556..bee9d7febd 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 e1cf11ad0f..95697941d3 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 3a7c28f60f..0000000000 --- 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 cd2ffafc4c..bd252f6b0c 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 0000000000..ba398a5bf8 --- /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-fr/strings.xml b/android/engine/src/main/res/values-fr/strings.xml index 71df4675e0..5332ca15cd 100644 --- a/android/engine/src/main/res/values-fr/strings.xml +++ b/android/engine/src/main/res/values-fr/strings.xml @@ -64,11 +64,12 @@ Erreur rencontrée, formulaire non enregistré Traitement des données. Veuillez patienter Êtes-vous sûr de vouloir revenir en arrière ? - Êtes-vous sûr de vouloir annuler les réponses ? - Annuler les modifications - Annuler cette action - Enregistrer un brouillon partiel + Si vous partez sans sauvegarder, toutes vos modifications ne seront pas enregistrées. + Vous avez des modifications non enregistrées. + Annuler les modifications + Enregistrer comme brouillon Ne pas annuler cette action + Abandonner les modifications Oui Des erreurs de validation ont été détectées. Corrigez les erreurs et soumettez à nouveau. Échec de la validation @@ -179,4 +180,7 @@ REESSAYER Il y a des données non synchronisées Le contact du superviseur est manquant ou le numéro de téléphone fourni n\'est pas valide + APPLIQUER LE FILLTRE + Enregistrer les modifications du brouillon + Voulez-vous enregistrer les modifications du brouillon ? diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index cb1930e273..7bc1d98de1 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 31e2665ea1..7ea15619cc 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/data/local/ContentCacheTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/ContentCacheTest.kt new file mode 100644 index 0000000000..713d0125b7 --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/ContentCacheTest.kt @@ -0,0 +1,84 @@ +/* + * 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.data.local + +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.Resource +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.smartregister.fhircore.engine.robolectric.RobolectricTest +import org.smartregister.fhircore.engine.rule.CoroutineTestRule + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +class ContentCacheTest : RobolectricTest() { + + @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) val coroutineTestRule = CoroutineTestRule() + + @Inject lateinit var contentCache: ContentCache + + private val resourceId = "123" + private val mockResource: Resource = Questionnaire().apply { id = resourceId } + + @Before + fun setUp() { + hiltRule.inject() + } + + @Test + fun `saveResource should store resource in cache`() = runTest { + contentCache.saveResource(mockResource) + + val cachedResource = contentCache.getResource(mockResource.resourceType, mockResource.idPart) + assertNotNull(cachedResource) + assertEquals(mockResource.idPart, cachedResource?.idPart) + } + + @Test + fun `getResource should return the correct resource from cache`() = runTest { + contentCache.saveResource(mockResource) + + val result = contentCache.getResource(mockResource.resourceType, mockResource.idPart) + assertEquals(mockResource.idPart, result?.idPart) + } + + @Test + fun `getResource should return null if resource does not exist`() = runTest { + val result = contentCache.getResource(mockResource.resourceType, "non_existing_id") + assertNull(result) + } + + @Test + fun `invalidate should clear all resources from cache`() = runTest { + contentCache.saveResource(mockResource) + contentCache.invalidate() + + val result = contentCache.getResource(mockResource.resourceType, mockResource.idPart) + assertNull(result) + } +} diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirCarePlanGeneratorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirCarePlanGeneratorTest.kt index 9f89f92c32..09f9c7572a 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirCarePlanGeneratorTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/task/FhirCarePlanGeneratorTest.kt @@ -34,7 +34,6 @@ import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.runs @@ -106,12 +105,15 @@ import org.mockito.ArgumentMatchers.anyBoolean import org.smartregister.fhircore.engine.app.fakes.Faker import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.configuration.event.EventTriggerCondition import org.smartregister.fhircore.engine.configuration.event.EventWorkflow import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.ResourceConfig import org.smartregister.fhircore.engine.robolectric.RobolectricTest import org.smartregister.fhircore.engine.rule.CoroutineTestRule +import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.REFERENCE import org.smartregister.fhircore.engine.util.extension.SDF_YYYY_MM_DD import org.smartregister.fhircore.engine.util.extension.asReference @@ -130,6 +132,7 @@ import org.smartregister.fhircore.engine.util.extension.plusYears import org.smartregister.fhircore.engine.util.extension.referenceValue import org.smartregister.fhircore.engine.util.extension.updateDependentTaskDueDate import org.smartregister.fhircore.engine.util.extension.valueToString +import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor import org.smartregister.fhircore.engine.util.helper.TransformSupportServices @OptIn(ExperimentalCoroutinesApi::class) @@ -146,12 +149,21 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { @Inject lateinit var fhirEngine: FhirEngine + @Inject lateinit var dispatcherProvider: DispatcherProvider + + @Inject lateinit var configService: ConfigService + + @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper + + @Inject lateinit var fhirPathDataExtractor: FhirPathDataExtractor + @Inject lateinit var configurationRegistry: ConfigurationRegistry private val context: Context = ApplicationProvider.getApplicationContext() private val knowledgeManager = KnowledgeManager.create(context) private val fhirContext: FhirContext = FhirContext.forCached(FhirVersionEnum.R4) + private lateinit var defaultRepository: DefaultRepository private lateinit var fhirResourceUtil: FhirResourceUtil private lateinit var fhirCarePlanGenerator: FhirCarePlanGenerator private lateinit var structureMapUtilities: StructureMapUtilities @@ -159,7 +171,7 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { private lateinit var encounter: Encounter private lateinit var opv0: Task private lateinit var opv1: Task - private val defaultRepository: DefaultRepository = mockk(relaxed = true) + private val iParser: IParser = fhirContext.newJsonParser() private val jsonParser = fhirContext.getCustomJsonParser() private val xmlParser = fhirContext.newXmlParser() @@ -168,7 +180,21 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { fun setup() { hiltRule.inject() structureMapUtilities = StructureMapUtilities(transformSupportServices.simpleWorkerContext) - every { defaultRepository.fhirEngine } returns fhirEngine + defaultRepository = + spyk( + DefaultRepository( + fhirEngine = fhirEngine, + dispatcherProvider = dispatcherProvider, + sharedPreferencesHelper = sharedPreferencesHelper, + configurationRegistry = mockk(), + configService = configService, + configRulesExecutor = mockk(), + fhirPathDataExtractor = fhirPathDataExtractor, + parser = iParser, + context = context, + ), + ) + coEvery { defaultRepository.create(anyBoolean(), any()) } returns listOf() fhirResourceUtil = @@ -898,6 +924,8 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { coEvery { fhirEngine.update(any()) } just runs coEvery { fhirEngine.get("528a8603-2e43-4a2e-a33d-1ec2563ffd3e") } returns structureMapReferral + .encodeResourceToString() + .decodeResourceFromString() // Ensure 'months' Duration code is correctly escaped coEvery { fhirEngine.search(Search(ResourceType.CarePlan)) } returns listOf( SearchResult( @@ -981,6 +1009,8 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { runs coEvery { fhirEngine.get("528a8603-2e43-4a2e-a33d-1ec2563ffd3e") } returns structureMap + .encodeResourceToString() + .decodeResourceFromString() // Ensure 'months' Duration code is correctly escaped coEvery { fhirEngine.search(Search(ResourceType.CarePlan)) } returns listOf() @@ -2529,8 +2559,12 @@ class FhirCarePlanGeneratorTest : RobolectricTest() { coEvery { fhirEngine.search(Search(ResourceType.CarePlan)) } returns listOf() coEvery { fhirEngine.get(structureMapRegister.logicalId) } returns structureMapRegister + .encodeResourceToString() + .decodeResourceFromString() // Ensure 'months' Duration code is correctly escaped coEvery { fhirEngine.get("528a8603-2e43-4a2e-a33d-1ec2563ffd3e") } returns structureMapReferral + .encodeResourceToString() + .decodeResourceFromString() // Ensure 'months' Duration code is correctly escaped return PlanDefinitionResources( planDefinition, 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 36296d09c9..401ef8cc2c 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 0000000000..2cd1c0dbf7 --- /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 5e55a48403..be5cffa260 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 f2dff7fe0d..0e3d8d2a8f 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/engine/src/test/resources/plans/child-immunization-schedule/structure-map-register.txt b/android/engine/src/test/resources/plans/child-immunization-schedule/structure-map-register.txt index 0b200d8943..57281e9223 100644 --- a/android/engine/src/test/resources/plans/child-immunization-schedule/structure-map-register.txt +++ b/android/engine/src/test/resources/plans/child-immunization-schedule/structure-map-register.txt @@ -123,7 +123,7 @@ group extractTaskRestriction(source subject: Patient, target task: Task, source startDateTime.value = evaluate(start, $this.value.substring(0,10) + 'T00:00:00.00Z') "rule_period_start"; subject -> taskRestrictionPeriod.end = create('dateTime') as endDateTime, - endDateTime.value = evaluate(start, (($this + (((restrictionEndDate).toString() + ' \'days\'')).toQuantity()).value.substring(0,10)) + 'T00:00:00.00Z') "rule_period_end"; + endDateTime.value = evaluate(start, (($this + (((restrictionEndDate).toString() + ' days')).toQuantity()).value.substring(0,10)) + 'T00:00:00.00Z') "rule_period_end"; subject -> task.restriction = taskRestriction "rule_restriction_period"; } "rule_task_restriction_period"; diff --git a/android/engine/src/test/resources/plans/disease-followup/structure-map.txt b/android/engine/src/test/resources/plans/disease-followup/structure-map.txt index 3e5069a7d1..36e3ab2eac 100644 --- a/android/engine/src/test/resources/plans/disease-followup/structure-map.txt +++ b/android/engine/src/test/resources/plans/disease-followup/structure-map.txt @@ -27,7 +27,9 @@ group ExtractActivityDetail(source subject : Patient, source definition: Activit subject then ExtractDiseaseCode(src, det) "r_act_det_data"; subject -> det.scheduled = evaluate(definition, $this.timing) as timing, evaluate(timing, $this.repeat) as repeat then { - subject -> evaluate(subject, today()) as dueDate, evaluate(subject, today() + ((repeat.count.toString().toInteger() - 1).toString() + ' \'months\'').toQuantity()) as maxDate + subject -> evaluate(subject, ((repeat.count.toString().toInteger() - 1).toString() + ' months').toQuantity()) as duration, + duration.code = 'months', + evaluate(subject, today()) as dueDate, evaluate(subject, today() + duration) as maxDate then ExtractTasks(dueDate, maxDate, repeat, subject, careplan, activity, timing) "r_tasks"; subject -> repeat.count = create('positiveInt') as c, c.value = evaluate(activity, $this.outcomeReference.count().value) "r_task_rep_count"; } "r_tim_repeat"; @@ -48,7 +50,8 @@ group ExtractTasks( // start of task is today OR first date of every month if future month | end is last day of given month create('date') as startOfMonth, startOfMonth.value = evaluate(dueDate, $this.value.substring(0,7) + '-01'), create('date') as start, start.value = evaluate(dueDate, iif($this = today(), $this, startOfMonth).value ), - evaluate(startOfMonth, ($this + '1 \'months\''.toQuantity()) - '1 \'days\''.toQuantity()) as end, + evaluate(startOfMonth, '1 month'.toQuantity()) as duration1month, duration1month.code = 'month', + evaluate(startOfMonth, ($this + duration1month) - '1 day'.toQuantity()) as end, create('Period') as period, careplan.contained = create('Task') as task then { subject then ExtractPeriod(start, end, period) "r_task_period_extr"; @@ -67,7 +70,9 @@ group ExtractTasks( subject -> task.reasonReference = create('Reference') as ref, ref.reference = 'Questionnaire/e14b5743-0a06-4ab5-aaee-ac158d4cb64f' "r_task_reason_ref"; subject -> activity.outcomeReference = reference(task) "r_cp_task_ref"; subject -> timing.event = evaluate(period, $this.start) "r_activity_timing"; - repeat -> evaluate(period, $this.start + (repeat.period.toString() + ' \'months\'').toQuantity()) as nextDueDate + repeat -> evaluate(period, (repeat.period.toString() + ' months').toQuantity()) as duration, + duration.code = 'months', + evaluate(period, $this.start + duration) as nextDueDate then ExtractTasks(nextDueDate, maxDate, repeat, subject, careplan, activity, timing) "r_task_repeat"; } "r_cp_acti_outcome"; } diff --git a/android/engine/src/test/resources/plans/household-routine-visit/structure-map.txt b/android/engine/src/test/resources/plans/household-routine-visit/structure-map.txt index 2af35dee31..82708d89b9 100644 --- a/android/engine/src/test/resources/plans/household-routine-visit/structure-map.txt +++ b/android/engine/src/test/resources/plans/household-routine-visit/structure-map.txt @@ -84,7 +84,9 @@ group ExtractTimingCode(source subject : Group, target concept: CodeableConcept) group ExtractPeriod_1m(source offset : DateType, target period: Period){ offset -> offset as start, - evaluate(offset, $this + 1 'month') as end then + evaluate(offset, "1 month".toQuantity()) as duration1month, + duration1month.code = 'month', + evaluate(offset, $this + duration1month) as end then ExtractPeriod(start, end, period) "r_period"; } diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 7679347aec..73f98b8d69 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -2,11 +2,11 @@ accompanist = "0.23.1" activity-compose = "1.8.2" androidJunit5 = "1.8.2.1" -androidx-camera = "1.4.0-rc02" +androidx-camera = "1.4.0" 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,14 @@ 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-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 +62,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 +82,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/build.gradle.kts b/android/quest/build.gradle.kts index b7855080e3..0a2841c66a 100644 --- a/android/quest/build.gradle.kts +++ b/android/quest/build.gradle.kts @@ -425,6 +425,22 @@ tasks.withType { testLogging { events = setOf(TestLogEvent.FAILED) } minHeapSize = "4608m" maxHeapSize = "4608m" + addTestListener( + object : TestListener { + override fun beforeSuite(p0: TestDescriptor?) {} + + override fun afterSuite(p0: TestDescriptor?, p1: TestResult?) {} + + override fun beforeTest(p0: TestDescriptor?) { + logger.lifecycle("Running test: $p0") + } + + override fun afterTest(p0: TestDescriptor?, p1: TestResult?) { + logger.lifecycle("Done executing: $p0") + } + }, + ) + // maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).takeIf { it > 0 } ?: 1 configure { isIncludeNoLocationClasses = true } } 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 01081c3ca8..aa34696bad 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 11316b4c6f..b15b59abf1 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 2cf5fd089a..4f06616a7f 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, @@ -158,6 +163,74 @@ class RegisterScreenTest { composeTestRule.onAllNodesWithTag(FAB_BUTTON_REGISTER_TEST_TAG, useUnmergedTree = true) } + @Test + fun testRegisterScreenWithPlaceholderColor() { + val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + val registerUiState = + RegisterUiState( + screenTitle = "Register101", + isFirstTimeSync = false, + registerConfiguration = + configurationRegistry + .retrieveConfiguration(ConfigType.Register, "householdRegister") + .copy( + searchBar = + RegisterContentConfig( + visible = true, + display = "Search", + placeholderColor = "#FF0000", + ), + ), + registerId = "register101", + 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, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), + ) + } + + // 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() @@ -168,9 +241,6 @@ class RegisterScreenTest { registerConfiguration = configurationRegistry.retrieveConfiguration(ConfigType.Register, "householdRegister"), registerId = "register101", - totalRecordsCount = 1, - filteredRecordsCount = 0, - pagesCount = 1, progressPercentage = flowOf(0), isSyncUpload = flowOf(false), params = emptyList(), @@ -188,6 +258,12 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 1, + ), onAppMainEvent = {}, searchQuery = searchText, currentPage = currentPage, @@ -213,13 +289,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 +313,7 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = registerUiCountState, onAppMainEvent = {}, searchQuery = searchText, currentPage = currentPage, @@ -258,9 +339,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 +361,12 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 0, + filteredRecordsCount = 0, + pagesCount = 1, + ), onAppMainEvent = {}, searchQuery = searchText, currentPage = currentPage, @@ -304,9 +388,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 +405,12 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), onAppMainEvent = {}, searchQuery = searchText, currentPage = currentPage, @@ -351,9 +438,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 +455,12 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), onAppMainEvent = {}, searchQuery = searchText, currentPage = currentPage, @@ -429,9 +519,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 +542,12 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), appDrawerUIState = AppDrawerUIState( currentSyncJobStatus = @@ -489,9 +582,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 +599,12 @@ class RegisterScreenTest { openDrawer = {}, onEvent = {}, registerUiState = registerUiState, + registerUiCountState = + RegisterUiCountState( + totalRecordsCount = 1, + filteredRecordsCount = 0, + pagesCount = 0, + ), appDrawerUIState = AppDrawerUIState( currentSyncJobStatus = CurrentSyncJobStatus.Succeeded(OffsetDateTime.now()), @@ -541,9 +637,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 +654,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 8d3967f60a..65531bc954 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 1cf8311dfd..9ca6820e8d 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/login/LoginActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginActivity.kt index f873e96df6..ebc621174f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/login/LoginActivity.kt @@ -24,9 +24,12 @@ import androidx.activity.viewModels import androidx.annotation.VisibleForTesting import androidx.compose.material.ExperimentalMaterialApi import androidx.core.os.bundleOf +import androidx.lifecycle.viewModelScope import androidx.work.WorkManager import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +import kotlinx.coroutines.launch +import org.smartregister.fhircore.engine.data.local.ContentCache import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator import org.smartregister.fhircore.engine.p2p.dao.P2PReceiverTransferDao import org.smartregister.fhircore.engine.p2p.dao.P2PSenderTransferDao @@ -47,6 +50,8 @@ open class LoginActivity : BaseMultiLanguageActivity() { @Inject lateinit var p2pReceiverTransferDao: P2PReceiverTransferDao + @Inject lateinit var contentCache: ContentCache + @Inject lateinit var workManager: WorkManager val loginViewModel by viewModels() @@ -84,7 +89,7 @@ open class LoginActivity : BaseMultiLanguageActivity() { navigateToPinLogin(launchSetup = false) } } - + viewModelScope.launch { contentCache.invalidate() } navigateToHome.observe(loginActivity) { launchHomeScreen -> if (launchHomeScreen) { downloadNowWorkflowConfigs() 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 199dce8412..29c14d7acc 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 6595e8bba4..2bb53a31e0 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) @@ -366,16 +360,20 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { confirmButtonListener = { lifecycleScope.launch { retrieveQuestionnaireResponse()?.let { questionnaireResponse -> - viewModel.saveDraftQuestionnaire(questionnaireResponse) + viewModel.saveDraftQuestionnaire(questionnaireResponse, questionnaireConfig) + 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 ca814752f4..2fedb834e6 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 @@ -55,6 +59,7 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComp import org.hl7.fhir.r4.model.RelatedPerson import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType +import org.hl7.fhir.r4.model.StructureMap import org.smartregister.fhircore.engine.BuildConfig import org.smartregister.fhircore.engine.configuration.ConfigType import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry @@ -78,7 +83,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 +91,1173 @@ 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.loadResourceFromCache(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 { structureMapId -> + defaultRepository.loadResourceFromCache(structureMapId) } - } - } - .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 + else -> retrievePopulationResources(actionParameters) } - - 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() - } - .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 { + id = it.id + 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 9d31df58c9..9e6aff8fb8 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 515739ff58..4db108fa4f 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 0000000000..d319d0de2c --- /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 49698fa760..eabfe3e00d 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 3d8aca437c..ec82084b82 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 4121a7af8d..a78c065897 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 37f546db93..dbe547f334 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 e08ef94df7..519f19de69 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/main/res/values-fr/strings.xml b/android/quest/src/main/res/values-fr/strings.xml index ae4d01024d..c8d989da49 100644 --- a/android/quest/src/main/res/values-fr/strings.xml +++ b/android/quest/src/main/res/values-fr/strings.xml @@ -85,7 +85,8 @@ Enregistrer comme ANC Résultats de la grossesse Registres - Pas d\'emplacement défini + Aucun emplacement défini. + Aucun emplacement à afficher sur la carte. Définir l\'emplacement pour synchroniser les données et charger les points de service Définir l\'emplacement @@ -99,7 +100,9 @@ Questionnaire introuvable, synchroniser tous les questionnaires pour régler ce problème Pas de visites Réponse du questionnaire invalide - Version + La validation des ressources extraites a échoué. Veuillez vérifier les journaux. + Une erreur est survenue lors de la génération du CarePlan. Veuillez vérifier les journaux. + Version de l\'application Type de sujet manquant dans le questionnaire. Fournir Questionnaire.subjectType pour résoudre le problème. QuestionnaireConfig est requis mais manquant Erreur dans le remplissage de certains champs du questionnaire. Réponse au questionnaire non valide. @@ -113,8 +116,23 @@ Les services de localisation sont désactivés. Souhaitez-vous les activer ? Lien %1$s copié avec succès Soumettre - - + La ressource de base pour la configuration du GeoWidget DOIT être l\'emplacement. + Aucune configuration fournie pour la barre de recherche. + Aucun emplacement trouvé correspondant au texte \"%1$s\" + code_QR + Demande d\'autorisation de caméra refusée. Le code-barres pourrait ne pas fonctionner comme prévu. + Placez votre caméra sur le code QR pour commencer à scanner. + Ajouter un code QR + Scanner le code QR + Placez votre caméra sur l\'intégralité du code QR pour commencer à scanner. + Échec de récupération de la position GPS. + Quitter l\'application + Êtes-vous sûr de vouloir quitter l\'application ? + Configuration de vue multi-sélection manquante. Veuillez fournir les configurations pour que la vue puisse être rendue. + \"%1$d hors de%2$d L\'emplacement (ou les emplacements) n\'ont pas de coordonnées\" + Tous Les emplacements ont été rendus avec succès\" + %1$d Les emplacements correspondants ont été rendus avec succès. + Annuler l\'ajout de l\'emplacement @@ -174,9 +192,9 @@ Date Heure - Sélectionner l\'heure + Sélectionner l\'heure - + Prendre une photo Aperçu des photos Aperçu de l\'icône du fichier @@ -253,6 +271,7 @@ Optionnel Requis Requis\n + Ajouter %1$s 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 578a91120f..e897d4b8a7 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 @@ -93,6 +94,7 @@ import org.smartregister.fhircore.engine.configuration.LinkIdType import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig import org.smartregister.fhircore.engine.configuration.UniqueIdAssignmentConfig import org.smartregister.fhircore.engine.configuration.app.ConfigService +import org.smartregister.fhircore.engine.data.local.ContentCache import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.ActionParameterType @@ -111,9 +113,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 +133,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper - @Inject lateinit var fhirValidatorProvider: Provider + @Inject lateinit var fhirValidatorRequestHandlerProvider: Lazy @Inject lateinit var configService: ConfigService @@ -145,6 +149,8 @@ class QuestionnaireViewModelTest : RobolectricTest() { @Inject lateinit var knowledgeManager: KnowledgeManager + @Inject lateinit var contentCache: ContentCache + private lateinit var samplePatientRegisterQuestionnaire: Questionnaire private lateinit var questionnaireConfig: QuestionnaireConfig private lateinit var questionnaireViewModel: QuestionnaireViewModel @@ -210,7 +216,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { resourceDataRulesExecutor = resourceDataRulesExecutor, transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, - fhirValidatorProvider = fhirValidatorProvider, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, fhirOperator = fhirOperator, fhirPathDataExtractor = fhirPathDataExtractor, configurationRegistry = configurationRegistry, @@ -639,6 +645,46 @@ class QuestionnaireViewModelTest : RobolectricTest() { Assert.assertEquals(questionnaireConfig.id, questionnaire?.id?.extractLogicalIdUuid()) } + @Test + fun testRetrieveQuestionnaireShouldReturnValidQuestionnaireFromCache() = runTest { + coEvery { fhirEngine.get(ResourceType.Questionnaire, questionnaireConfig.id) } returns + samplePatientRegisterQuestionnaire + + coEvery { defaultRepository.loadResource(questionnaireConfig.id) } returns + samplePatientRegisterQuestionnaire + + contentCache.saveResource(samplePatientRegisterQuestionnaire) + val questionnaire = + questionnaireViewModel.retrieveQuestionnaire( + questionnaireConfig = questionnaireConfig, + ) + Assert.assertEquals( + samplePatientRegisterQuestionnaire.idPart, + contentCache.getResource(ResourceType.Questionnaire, questionnaireConfig.id)?.idPart, + ) + Assert.assertNotNull(questionnaire) + Assert.assertEquals(questionnaireConfig.id, questionnaire?.id?.extractLogicalIdUuid()) + } + + @Test + fun testRetrieveQuestionnaireShouldReturnValidQuestionnaireFromDatabase() = runTest { + coEvery { fhirEngine.get(ResourceType.Questionnaire, questionnaireConfig.id) } returns + samplePatientRegisterQuestionnaire + + coEvery { defaultRepository.loadResource(questionnaireConfig.id) } returns + samplePatientRegisterQuestionnaire + + val questionnaire = + questionnaireViewModel.retrieveQuestionnaire( + questionnaireConfig = questionnaireConfig, + ) + + Assert.assertNotNull(questionnaire) + Assert.assertEquals(questionnaireConfig.id, questionnaire?.id?.extractLogicalIdUuid()) + + coVerify(exactly = 1) { defaultRepository.loadResource(questionnaireConfig.id) } + } + @Test fun testPopulateQuestionnaireShouldPrePopulatedQuestionnaireWithComputedValues() = runTest { val questionnaireViewModelInstance = @@ -650,7 +696,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, fhirOperator = fhirOperator, - fhirValidatorProvider = fhirValidatorProvider, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, fhirPathDataExtractor = fhirPathDataExtractor, configurationRegistry = configurationRegistry, ) @@ -734,7 +780,10 @@ class QuestionnaireViewModelTest : RobolectricTest() { }, ) } - questionnaireViewModel.saveDraftQuestionnaire(questionnaireResponse) + questionnaireViewModel.saveDraftQuestionnaire( + questionnaireResponse, + QuestionnaireConfig("qr-id-1"), + ) Assert.assertEquals( QuestionnaireResponse.QuestionnaireResponseStatus.INPROGRESS, questionnaireResponse.status, @@ -742,6 +791,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 +1215,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 +1407,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 +1610,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { } listResource.addEntry(listEntryComponent) addContained(listResource) + status = QuestionnaireResponse.QuestionnaireResponseStatus.COMPLETED } coEvery { @@ -1453,6 +1619,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { resourceType = ResourceType.Patient, questionnaireId = questionnaireConfig.id, encounterId = null, + questionnaireResponseStatus = questionnaireConfig.questionnaireResponseStatus(), ) } returns previousQuestionnaireResponse @@ -1829,7 +1996,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, fhirOperator = fhirOperator, - fhirValidatorProvider = fhirValidatorProvider, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, fhirPathDataExtractor = fhirPathDataExtractor, configurationRegistry = configurationRegistry, ) @@ -1891,7 +2058,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, fhirOperator = fhirOperator, - fhirValidatorProvider = fhirValidatorProvider, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, fhirPathDataExtractor = fhirPathDataExtractor, configurationRegistry = configurationRegistry, ) @@ -1966,7 +2133,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { transformSupportServices = mockk(), sharedPreferencesHelper = sharedPreferencesHelper, fhirOperator = fhirOperator, - fhirValidatorProvider = fhirValidatorProvider, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, fhirPathDataExtractor = fhirPathDataExtractor, configurationRegistry = configurationRegistry, ) @@ -2063,6 +2230,87 @@ class QuestionnaireViewModelTest : RobolectricTest() { Assert.assertTrue(result.first!!.find("linkid-1") == null) } + @Test + fun testThatPopulateQuestionnaireReturnsQuestionnaireResponseWithIdValue() = runTest { + val questionnaireViewModelInstance = + QuestionnaireViewModel( + defaultRepository = defaultRepository, + dispatcherProvider = dispatcherProvider, + fhirCarePlanGenerator = fhirCarePlanGenerator, + resourceDataRulesExecutor = resourceDataRulesExecutor, + transformSupportServices = mockk(), + sharedPreferencesHelper = sharedPreferencesHelper, + fhirOperator = fhirOperator, + fhirValidatorRequestHandlerProvider = fhirValidatorRequestHandlerProvider, + fhirPathDataExtractor = fhirPathDataExtractor, + configurationRegistry = configurationRegistry, + ) + val questionnaireConfig1 = + questionnaireConfig.copy( + resourceType = ResourceType.Patient, + resourceIdentifier = patient.logicalId, + type = QuestionnaireType.EDIT.name, + ) + + val questionnaireWithInitialValue = + Questionnaire().apply { + id = questionnaireConfig1.id + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "group-1" + type = Questionnaire.QuestionnaireItemType.GROUP + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkid-1" + type = Questionnaire.QuestionnaireItemType.STRING + addInitial(Questionnaire.QuestionnaireItemInitialComponent(StringType("---"))) + }, + ) + }, + ) + + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "linkid-2" + type = Questionnaire.QuestionnaireItemType.STRING + }, + ) + } + val qrId = "qr-id-1" + val questionnaireResponse = + QuestionnaireResponse().apply { + id = qrId + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "group-1" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "linkid-1" + }, + ) + }, + ) + } + coEvery { + fhirEngine.get(questionnaireConfig1.resourceType!!, questionnaireConfig1.resourceIdentifier!!) + } returns patient + + coEvery { fhirEngine.search(any()) } returns + listOf( + SearchResult(questionnaireResponse, included = null, revIncluded = null), + ) + + Assert.assertNotNull(questionnaireResponse.find("linkid-1")) + val result = + questionnaireViewModelInstance.populateQuestionnaire( + questionnaireWithInitialValue, + questionnaireConfig1, + emptyList(), + ) + Assert.assertNotNull(result.first) + Assert.assertEquals(qrId, result.first!!.id) + } + @Test fun testExcludeNestedItemFromQuestionnairePrepopulation() { val item1 = QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "1" } 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 66775f2953..a4a903b17e 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 3746f047c9..8f7e92738f 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 b30b11a894..fb432e3969 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 0000000000..c9d33123c4 --- /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 0690ca00f6..18c446dc6b 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 0000000000..d0151cc023 --- /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 0c18e1857c..8b3662fc9a 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).