From bcff5264c1297d8a02d61b78d55ee25dea1c6e85 Mon Sep 17 00:00:00 2001 From: Rkareko <47570855+Rkareko@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:22:19 +0300 Subject: [PATCH] Delete saved draft feature (#3631) * Add delete draft workflow * Add view model * Add logic for soft deleteing drafts * Run the search for QuestinnaireResponse in view model scope * Move searchQuestionnaireResponse function to default repository * Use interpolated questionnaire config when launching delete draft fragment * Ensure delete draft db calls complete before dialog is dismissed * Add flag to indicate a drft has been deleted * Use aduti event to keep track of deleted drafts * Rename delete draft questionnaire workflow move delete draft classes to questionnaire package * Rename draft dialog fragment and view model * Add data class to hold alert dialog button properties Add ability to set alert dialog button color * Fix failing tests * Update CHANGELOG.md * Verify filtering by encounter works when when searching for latest QR * Run spotless Apply * Update test name Co-authored-by: Martin Ndegwa * Add documentation for delete draft functionality * Add qustionnaire draft dialog view tests * Run questionnaire soft deletion and audit event creation in a transaction block * Update docs --------- Co-authored-by: Martin Ndegwa --- CHANGELOG.md | 3 +- .../workflow/ApplicationWorkflow.kt | 3 + .../engine/data/local/DefaultRepository.kt | 44 ++++ .../fhircore/engine/ui/base/AlertDialogue.kt | 103 ++++++--- .../engine/src/main/res/values/strings.xml | 4 + .../data/local/DefaultRepositoryTest.kt | 133 ++++++++++++ .../engine/ui/base/AlertDialogueTest.kt | 37 +++- .../quest/navigation/MainNavigationScreen.kt | 5 + .../quest/navigation/NavigationArg.kt | 1 + .../ui/geowidget/GeoWidgetLauncherFragment.kt | 23 +- .../fhircore/quest/ui/main/AppMainActivity.kt | 11 +- .../ui/questionnaire/QuestionnaireActivity.kt | 48 +++-- .../QuestionnaireDraftDialogFragment.kt | 81 +++++++ .../QuestionnaireDraftDialogViewModel.kt | 126 +++++++++++ .../questionnaire/QuestionnaireViewModel.kt | 49 +---- .../quest/util/extensions/ConfigExtensions.kt | 9 + .../res/navigation/application_nav_graph.xml | 8 + .../QuestionnaireDraftDialogViewModelTest.kt | 197 ++++++++++++++++++ .../QuestionnaireViewModelTest.kt | 98 +-------- .../configuring/forms/save-form-as-draft.mdx | 120 ++++++++++- 20 files changed, 877 insertions(+), 226 deletions(-) create mode 100644 android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogFragment.kt create mode 100644 android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModel.kt create mode 100644 android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModelTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b6de70910..c6b01eff17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ 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 +- Added Save draft MVP functionality +- Added Delete saved draft feature ## [1.1.0] - 2024-02-15 diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt index ea58e771f5..68848c238e 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/workflow/ApplicationWorkflow.kt @@ -62,4 +62,7 @@ enum class ApplicationWorkflow { /** A workflow to launch pdf generation */ LAUNCH_PDF_GENERATION, + + /** A workflow to launch delete draft questionnaires */ + DELETE_DRAFT_QUESTIONNAIRE, } 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 63c0e4fc66..d457ef68d9 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 @@ -62,6 +62,8 @@ import org.hl7.fhir.r4.model.Group import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.Location 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.Reference import org.hl7.fhir.r4.model.RelatedPerson import org.hl7.fhir.r4.model.Resource @@ -1292,6 +1294,48 @@ constructor( ) .mapTo(ArrayDeque()) { it.resource } + /** + * 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 }, + ) + filter( + QuestionnaireResponse.QUESTIONNAIRE, + { value = questionnaireId.asReference(ResourceType.Questionnaire).reference }, + ) + if (!encounterId.isNullOrBlank()) { + filter( + QuestionnaireResponse.ENCOUNTER, + { + value = + encounterId.extractLogicalIdUuid().asReference(ResourceType.Encounter).reference + }, + ) + } + if (!questionnaireResponseStatus.isNullOrBlank()) { + filter( + QuestionnaireResponse.STATUS, + { value = of(questionnaireResponseStatus) }, + ) + } + } + val questionnaireResponses: List = search(search) + return questionnaireResponses.maxByOrNull { it.meta.lastUpdated } + } + /** * A wrapper data class to hold search results. All related resources are flattened into one Map * including the nested related resources as required by the Rules Engine facts. 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 bee9d7febd..bf61fd4717 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 @@ -43,6 +43,12 @@ enum class AlertIntent { data class AlertDialogListItem(val key: String, val value: String) +data class AlertDialogButton( + val listener: ((d: DialogInterface) -> Unit)? = null, + @StringRes val text: Int? = null, + val color: Int? = null, +) + object AlertDialogue { private val ITEMS_LIST_KEY = "alert_dialog_items_list" @@ -51,12 +57,9 @@ object AlertDialogue { alertIntent: AlertIntent, message: CharSequence, title: String? = null, - confirmButtonListener: ((d: DialogInterface) -> Unit)? = null, - @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, + confirmButton: AlertDialogButton? = null, + neutralButton: AlertDialogButton? = null, + negativeButton: AlertDialogButton? = null, cancellable: Boolean = false, options: Array? = null, ): AlertDialog { @@ -67,22 +70,48 @@ object AlertDialogue { setView(view) title?.let { setTitle(it) } setCancelable(cancellable) - neutralButtonListener?.let { - setNeutralButton(neutralButtonText) { d, _ -> neutralButtonListener.invoke(d) } + neutralButton?.listener?.let { + setNeutralButton( + neutralButton.text ?: R.string.questionnaire_alert_neutral_button_title, + ) { d, _ -> + neutralButton.listener.invoke(d) + } } - confirmButtonListener?.let { - setPositiveButton(confirmButtonText) { d, _ -> confirmButtonListener.invoke(d) } + confirmButton?.listener?.let { + setPositiveButton( + confirmButton.text ?: R.string.questionnaire_alert_confirm_button_title, + ) { d, _ -> + confirmButton.listener.invoke(d) + } } - negativeButtonListener?.let { - setNegativeButton(negativeButtonText) { d, _ -> negativeButtonListener.invoke(d) } + negativeButton?.listener?.let { + setNegativeButton( + negativeButton.text ?: R.string.questionnaire_alert_negative_button_title, + ) { d, _ -> + negativeButton.listener.invoke(d) + } } options?.run { setSingleChoiceItems(options.map { it.value }.toTypedArray(), -1, null) } } .show() + val neutralButtonColor = neutralButton?.color ?: R.color.grey_text_color dialog .getButton(AlertDialog.BUTTON_NEUTRAL) - .setTextColor(ContextCompat.getColor(context, R.color.grey_text_color)) + .setTextColor(ContextCompat.getColor(context, neutralButtonColor)) + + if (confirmButton?.color != null) { + dialog + .getButton(AlertDialog.BUTTON_POSITIVE) + .setTextColor(ContextCompat.getColor(context, confirmButton.color)) + } + + if (negativeButton?.color != null) { + dialog + .getButton(AlertDialog.BUTTON_NEGATIVE) + .setTextColor(ContextCompat.getColor(context, negativeButton.color)) + } + dialog.findViewById(R.id.pr_circular)?.apply { if (alertIntent == AlertIntent.PROGRESS) { this.show() @@ -115,8 +144,11 @@ object AlertDialogue { alertIntent = AlertIntent.INFO, message = message, title = title, - confirmButtonListener = confirmButtonListener, - confirmButtonText = confirmButtonText, + confirmButton = + AlertDialogButton( + listener = confirmButtonListener, + text = confirmButtonText, + ), ) } @@ -126,8 +158,11 @@ object AlertDialogue { alertIntent = AlertIntent.ERROR, message = message, title = title, - confirmButtonListener = { d -> d.dismiss() }, - confirmButtonText = R.string.questionnaire_alert_ack_button_title, + confirmButton = + AlertDialogButton( + listener = { d -> d.dismiss() }, + text = R.string.questionnaire_alert_ack_button_title, + ), ) } @@ -160,25 +195,28 @@ object AlertDialogue { alertIntent = AlertIntent.CONFIRM, message = context.getString(message), title = title?.let { context.getString(it) }, - confirmButtonListener = confirmButtonListener, - confirmButtonText = confirmButtonText, - neutralButtonListener = { d -> d.dismiss() }, - neutralButtonText = R.string.questionnaire_alert_neutral_button_title, + confirmButton = + AlertDialogButton( + listener = confirmButtonListener, + text = confirmButtonText, + ), + neutralButton = + AlertDialogButton( + listener = { d -> d.dismiss() }, + text = R.string.questionnaire_alert_neutral_button_title, + ), cancellable = false, options = options?.toTypedArray(), ) } - fun showCancelAlert( + fun showThreeButtonAlert( context: Context, @StringRes message: Int, @StringRes title: Int? = null, - confirmButtonListener: ((d: DialogInterface) -> Unit), - @StringRes confirmButtonText: Int, - neutralButtonListener: ((d: DialogInterface) -> Unit), - @StringRes neutralButtonText: Int, - negativeButtonListener: ((d: DialogInterface) -> Unit), - @StringRes negativeButtonText: Int, + confirmButton: AlertDialogButton? = null, + neutralButton: AlertDialogButton? = null, + negativeButton: AlertDialogButton? = null, cancellable: Boolean = true, options: List? = null, ): AlertDialog { @@ -187,12 +225,9 @@ object AlertDialogue { alertIntent = AlertIntent.CONFIRM, message = context.getString(message), title = title?.let { context.getString(it) }, - confirmButtonListener = confirmButtonListener, - confirmButtonText = confirmButtonText, - neutralButtonListener = neutralButtonListener, - neutralButtonText = neutralButtonText, - negativeButtonListener = negativeButtonListener, - negativeButtonText = negativeButtonText, + confirmButton = confirmButton, + neutralButton = neutralButton, + negativeButton = negativeButton, cancellable = cancellable, options = options?.toTypedArray(), ) diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index 9003f2e851..e22a4b54f1 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -76,6 +76,8 @@ Given details have validation errors. Resolve errors and submit again Validation Failed OK + Open draft + Delete draft Username Password Forgot Password @@ -202,4 +204,6 @@ APPLY FILTER Save draft changes Do you want to save draft changes? + Open draft changes + You can reopen a saved draft form to continue or delete it diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt index 4dcbb39ec1..04495a7962 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt @@ -65,6 +65,9 @@ import org.hl7.fhir.r4.model.Organization import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Period import org.hl7.fhir.r4.model.Procedure +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.RelatedPerson import org.hl7.fhir.r4.model.Resource @@ -80,6 +83,7 @@ import org.junit.Test import org.smartregister.fhircore.engine.app.AppConfigService 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.UniqueIdAssignmentConfig import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.configuration.event.EventTriggerCondition @@ -106,6 +110,7 @@ import org.smartregister.fhircore.engine.util.extension.generateMissingId import org.smartregister.fhircore.engine.util.extension.loadResource import org.smartregister.fhircore.engine.util.extension.plusDays import org.smartregister.fhircore.engine.util.extension.updateLastUpdated +import org.smartregister.fhircore.engine.util.extension.yesterday import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor @HiltAndroidTest @@ -134,6 +139,9 @@ class DefaultRepositoryTest : RobolectricTest() { private lateinit var dispatcherProvider: DefaultDispatcherProvider private lateinit var sharedPreferenceHelper: SharedPreferencesHelper private lateinit var defaultRepository: DefaultRepository + private lateinit var patient: Patient + private lateinit var questionnaireConfig: QuestionnaireConfig + private lateinit var samplePatientRegisterQuestionnaire: Questionnaire @Before fun setUp() { @@ -153,6 +161,25 @@ class DefaultRepositoryTest : RobolectricTest() { context = context, contentCache = contentCache, ) + patient = + Faker.buildPatient().apply { + address = + listOf( + Address().apply { + city = "Mombasa" + country = "Kenya" + }, + ) + } + questionnaireConfig = + QuestionnaireConfig( + id = "e5155788-8831-4916-a3f5-486915ce34b211", // Same as ID in + // sample_patient_registration.json + title = "Patient registration", + type = "DEFAULT", + ) + + samplePatientRegisterQuestionnaire = Questionnaire().apply { id = questionnaireConfig.id } } @Test @@ -1620,4 +1647,110 @@ class DefaultRepositoryTest : RobolectricTest() { Assert.assertEquals(2, location4SubLocations.size) Assert.assertEquals(location5.logicalId, location4SubLocations.last().logicalId) } + + @Test + fun testSearchLatestQuestionnaireResponseShouldReturnLatestQuestionnaireResponse() = + runTest(timeout = 90.seconds) { + val sampleEncounter = + Encounter().apply { + id = "encounter-id-1" + subject = patient.asReference() + } + Assert.assertNull( + defaultRepository.searchQuestionnaireResponse( + resourceId = patient.logicalId, + resourceType = ResourceType.Patient, + questionnaireId = questionnaireConfig.id, + encounterId = sampleEncounter.id, + ), + ) + + val questionnaireResponses = + listOf( + QuestionnaireResponse().apply { + id = "qr1" + meta.lastUpdated = Date() + subject = patient.asReference() + questionnaire = samplePatientRegisterQuestionnaire.asReference().reference + encounter = sampleEncounter.asReference() + }, + QuestionnaireResponse().apply { + id = "qr2" + meta.lastUpdated = yesterday() + subject = patient.asReference() + questionnaire = samplePatientRegisterQuestionnaire.asReference().reference + }, + ) + + // Add QuestionnaireResponse to database + fhirEngine.create( + patient, + samplePatientRegisterQuestionnaire, + *questionnaireResponses.toTypedArray(), + ) + + val latestQuestionnaireResponse = + defaultRepository.searchQuestionnaireResponse( + resourceId = patient.logicalId, + resourceType = ResourceType.Patient, + questionnaireId = questionnaireConfig.id, + encounterId = sampleEncounter.id, + ) + Assert.assertNotNull(latestQuestionnaireResponse) + Assert.assertEquals("QuestionnaireResponse/qr1", latestQuestionnaireResponse?.id) + Assert.assertEquals( + "Encounter/encounter-id-1", + latestQuestionnaireResponse?.encounter?.reference, + ) + } + + @Test + fun testSearchLatestQuestionnaireResponseWhenSaveDraftIsTrueShouldReturnLatestQuestionnaireResponse() = + runTest(timeout = 90.seconds) { + Assert.assertNull( + defaultRepository.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 = + defaultRepository.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) + } } 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 401ef8cc2c..bc0aeead93 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 @@ -53,10 +53,16 @@ class AlertDialogueTest : ActivityRobolectricTest() { alertIntent = AlertIntent.ERROR, message = getString(R.string.questionnaire_alert_invalid_message), title = getString(R.string.questionnaire_alert_invalid_title), - confirmButtonText = R.string.questionnaire_alert_confirm_button_title, - confirmButtonListener = { confirmCalled.add(true) }, - neutralButtonText = R.string.questionnaire_alert_ack_button_title, - neutralButtonListener = { neutralCalled.add(true) }, + confirmButton = + AlertDialogButton( + text = R.string.questionnaire_alert_confirm_button_title, + listener = { confirmCalled.add(true) }, + ), + neutralButton = + AlertDialogButton( + text = R.string.questionnaire_alert_ack_button_title, + listener = { neutralCalled.add(true) }, + ), options = arrayOf(AlertDialogListItem("a", "A"), AlertDialogListItem("b", "B")), ) @@ -143,16 +149,25 @@ class AlertDialogueTest : ActivityRobolectricTest() { @Test fun testShowCancelAlertShowsWithCorrectData() { - AlertDialogue.showCancelAlert( + AlertDialogue.showThreeButtonAlert( context = context, message = R.string.questionnaire_in_progress_alert_back_pressed_message, title = R.string.questionnaire_alert_back_pressed_title, - confirmButtonListener = {}, - 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, + confirmButton = + AlertDialogButton( + listener = {}, + text = R.string.questionnaire_alert_back_pressed_save_draft_button_title, + ), + neutralButton = + AlertDialogButton( + listener = {}, + text = R.string.questionnaire_alert_back_pressed_button_title, + ), + negativeButton = + AlertDialogButton( + listener = {}, + text = R.string.questionnaire_alert_negative_button_title, + ), ) val dialog = shadowOf(ShadowAlertDialog.getLatestAlertDialog()) diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt index a3ff11c5cc..989237f4f9 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/MainNavigationScreen.kt @@ -70,5 +70,10 @@ sealed class MainNavigationScreen( route = org.smartregister.fhircore.quest.R.id.summaryBottomSheetFragment, ) + data object AlertDialogFragment : + MainNavigationScreen( + route = org.smartregister.fhircore.quest.R.id.questionnaireDraftDialogFragment, + ) + fun eventId(id: String) = route.toString() + "_" + id } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/NavigationArg.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/NavigationArg.kt index 618bf1823b..7842917831 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/NavigationArg.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/navigation/NavigationArg.kt @@ -28,4 +28,5 @@ object NavigationArg { const val REPORT_ID = "reportId" const val PARAMS = "params" const val TOOL_BAR_HOME_NAVIGATION = "toolBarHomeNavigation" + const val QUESTIONNAIRE_CONFIG = "questionnaireConfig" } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt index 2b4d302b1e..2eab074a82 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/geowidget/GeoWidgetLauncherFragment.kt @@ -53,6 +53,7 @@ import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.geowidget.GeoWidgetConfiguration import org.smartregister.fhircore.engine.sync.OnSyncListener import org.smartregister.fhircore.engine.sync.SyncListenerManager +import org.smartregister.fhircore.engine.ui.base.AlertDialogButton import org.smartregister.fhircore.engine.ui.base.AlertDialogue import org.smartregister.fhircore.engine.ui.base.AlertIntent import org.smartregister.fhircore.engine.ui.theme.AppTheme @@ -271,15 +272,21 @@ class GeoWidgetLauncherFragment : Fragment(), OnSyncListener { alertIntent = AlertIntent.INFO, message = geoWidgetConfiguration.noResults?.message!!, title = geoWidgetConfiguration.noResults?.title!!, - confirmButtonListener = { - geoWidgetConfiguration.noResults - ?.actionButton - ?.actions - ?.handleClickEvent(findNavController()) - }, - confirmButtonText = R.string.positive_button_location_set, + confirmButton = + AlertDialogButton( + listener = { + geoWidgetConfiguration.noResults + ?.actionButton + ?.actions + ?.handleClickEvent(findNavController()) + }, + text = R.string.positive_button_location_set, + ), cancellable = true, - neutralButtonListener = {}, + neutralButton = + AlertDialogButton( + listener = {}, + ), ) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt index c1c47cdbfd..3a239198b1 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt @@ -52,6 +52,7 @@ import org.smartregister.fhircore.engine.domain.model.LauncherType import org.smartregister.fhircore.engine.rulesengine.services.LocationCoordinate import org.smartregister.fhircore.engine.sync.OnSyncListener import org.smartregister.fhircore.engine.sync.SyncListenerManager +import org.smartregister.fhircore.engine.ui.base.AlertDialogButton import org.smartregister.fhircore.engine.ui.base.AlertDialogue import org.smartregister.fhircore.engine.ui.base.AlertIntent import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity @@ -318,8 +319,14 @@ open class AppMainActivity : BaseMultiLanguageActivity(), QuestionnaireHandler, title = getString(R.string.exit_app), message = getString(R.string.exit_app_message), cancellable = false, - confirmButtonListener = { finish() }, - neutralButtonListener = { dialog -> dialog.dismiss() }, + confirmButton = + AlertDialogButton( + listener = { finish() }, + ), + neutralButton = + AlertDialogButton( + listener = { dialog -> dialog.dismiss() }, + ), ) } else navHostFragment.navController.navigateUp() } 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 2bb53a31e0..e0608a8062 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 @@ -50,6 +50,7 @@ import org.smartregister.fhircore.engine.configuration.app.LocationLogOptions import org.smartregister.fhircore.engine.domain.model.ActionParameter import org.smartregister.fhircore.engine.domain.model.isReadOnly import org.smartregister.fhircore.engine.domain.model.isSummary +import org.smartregister.fhircore.engine.ui.base.AlertDialogButton import org.smartregister.fhircore.engine.ui.base.AlertDialogue import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity import org.smartregister.fhircore.engine.util.DispatcherProvider @@ -351,29 +352,40 @@ class QuestionnaireActivity : BaseMultiLanguageActivity() { if (questionnaireConfig.isReadOnly()) { finish() } else if (questionnaireConfig.saveDraft) { - AlertDialogue.showCancelAlert( + AlertDialogue.showThreeButtonAlert( context = this, message = org.smartregister.fhircore.engine.R.string .questionnaire_in_progress_alert_back_pressed_message, title = org.smartregister.fhircore.engine.R.string.questionnaire_alert_back_pressed_title, - confirmButtonListener = { - lifecycleScope.launch { - retrieveQuestionnaireResponse()?.let { questionnaireResponse -> - viewModel.saveDraftQuestionnaire(questionnaireResponse, questionnaireConfig) - finish() - } - } - }, - confirmButtonText = - org.smartregister.fhircore.engine.R.string - .questionnaire_alert_back_pressed_save_draft_button_title, - neutralButtonListener = {}, - neutralButtonText = - 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, + confirmButton = + AlertDialogButton( + listener = { + lifecycleScope.launch { + retrieveQuestionnaireResponse()?.let { questionnaireResponse -> + viewModel.saveDraftQuestionnaire(questionnaireResponse, questionnaireConfig) + finish() + } + } + }, + text = + org.smartregister.fhircore.engine.R.string + .questionnaire_alert_back_pressed_save_draft_button_title, + color = org.smartregister.fhircore.engine.R.color.colorPrimary, + ), + neutralButton = + AlertDialogButton( + listener = {}, + text = + org.smartregister.fhircore.engine.R.string.questionnaire_alert_neutral_button_title, + ), + negativeButton = + AlertDialogButton( + listener = { finish() }, + text = + org.smartregister.fhircore.engine.R.string.questionnaire_alert_negative_button_title, + color = org.smartregister.fhircore.engine.R.color.colorPrimary, + ), ) } else { AlertDialogue.showConfirmAlert( diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogFragment.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogFragment.kt new file mode 100644 index 0000000000..7e6e277005 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogFragment.kt @@ -0,0 +1,81 @@ +/* + * 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.questionnaire + +import android.app.Dialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.runBlocking +import org.smartregister.fhircore.engine.R +import org.smartregister.fhircore.engine.ui.base.AlertDialogButton +import org.smartregister.fhircore.engine.ui.base.AlertDialogue +import org.smartregister.fhircore.engine.util.extension.getActivity +import org.smartregister.fhircore.quest.ui.shared.QuestionnaireHandler + +@AndroidEntryPoint +class QuestionnaireDraftDialogFragment() : DialogFragment() { + + private val questionnaireDraftDialogFragmentArgs by + navArgs() + private val questionnaireDraftDialogViewModel by viewModels() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return AlertDialogue.showThreeButtonAlert( + context = requireContext(), + message = org.smartregister.fhircore.engine.R.string.open_draft_changes_message, + title = org.smartregister.fhircore.engine.R.string.open_draft_changes_title, + confirmButton = + AlertDialogButton( + listener = { + if (requireContext().getActivity() is QuestionnaireHandler) { + (requireContext().getActivity() as QuestionnaireHandler).launchQuestionnaire( + context = requireContext().getActivity()!!.baseContext, + questionnaireConfig = questionnaireDraftDialogFragmentArgs.questionnaireConfig, + actionParams = listOf(), + ) + } + }, + text = + org.smartregister.fhircore.engine.R.string.questionnaire_alert_open_draft_button_title, + color = R.color.colorPrimary, + ), + neutralButton = + AlertDialogButton( + listener = {}, + text = + org.smartregister.fhircore.engine.R.string.questionnaire_alert_neutral_button_title, + ), + negativeButton = + AlertDialogButton( + listener = { + runBlocking { + questionnaireDraftDialogViewModel.deleteDraft( + questionnaireDraftDialogFragmentArgs.questionnaireConfig, + ) + } + }, + text = + org.smartregister.fhircore.engine.R.string + .questionnaire_alert_delete_draft_button_title, + color = R.color.colorError, + ), + ) + } +} diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModel.kt new file mode 100644 index 0000000000..360a74b426 --- /dev/null +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModel.kt @@ -0,0 +1,126 @@ +/* + * 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.questionnaire + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.Date +import javax.inject.Inject +import org.hl7.fhir.r4.model.AuditEvent +import org.hl7.fhir.r4.model.AuditEvent.AuditEventSourceComponent +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Period +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.ResourceType +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +import org.smartregister.fhircore.engine.data.local.DefaultRepository +import org.smartregister.fhircore.engine.util.SharedPreferenceKey +import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.extension.asReference +import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid + +@HiltViewModel +class QuestionnaireDraftDialogViewModel +@Inject +constructor( + val defaultRepository: DefaultRepository, + val sharedPreferencesHelper: SharedPreferencesHelper, +) : ViewModel() { + + private val practitionerId: String? by lazy { + sharedPreferencesHelper + .read(SharedPreferenceKey.PRACTITIONER_ID.name, null) + ?.extractLogicalIdUuid() + } + + suspend fun deleteDraft(questionnaireConfig: QuestionnaireConfig?) { + if ( + questionnaireConfig == null || + questionnaireConfig.resourceIdentifier.isNullOrBlank() || + questionnaireConfig.resourceType == null + ) { + return + } + + val questionnaireResponse = + defaultRepository.searchQuestionnaireResponse( + resourceId = questionnaireConfig.resourceIdentifier!!, + resourceType = questionnaireConfig.resourceType!!, + questionnaireId = questionnaireConfig.id, + encounterId = null, + questionnaireResponseStatus = QuestionnaireResponseStatus.INPROGRESS.toCode(), + ) + + if (questionnaireResponse != null) { + questionnaireResponse.status = QuestionnaireResponseStatus.STOPPED + defaultRepository.applyDbTransaction { + defaultRepository.update(questionnaireResponse) + defaultRepository.addOrUpdate( + resource = createDeleteDraftAuditEvent(questionnaireConfig, questionnaireResponse), + ) + } + } + } + + fun createDeleteDraftAuditEvent( + questionnaireConfig: QuestionnaireConfig, + questionnaireResponse: QuestionnaireResponse, + ): AuditEvent { + return AuditEvent().apply { + entity = + listOf( + AuditEvent.AuditEventEntityComponent().apply { + what = Reference(questionnaireResponse.id) + }, + ) + source = + AuditEventSourceComponent().apply { + observer = + questionnaireConfig.resourceType?.let { + questionnaireConfig.resourceIdentifier?.asReference( + it, + ) + } + } + agent = + listOf( + AuditEvent.AuditEventAgentComponent().apply { + who = practitionerId?.asReference(ResourceType.Practitioner) + }, + ) + type = + Coding().apply { + system = AUDIT_EVENT_SYSTEM + code = AUDIT_EVENT_CODE + display = AUDIT_EVENT_DISPLAY + } + period = + Period().apply { + start = Date() + end = Date() + } + } + } + + companion object { + const val AUDIT_EVENT_SYSTEM = "http://smartregister.org/" + const val AUDIT_EVENT_CODE = "delete_draft" + const val AUDIT_EVENT_DISPLAY = "Delete Draft" + } +} 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 b2430fe56e..b6f8592318 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 @@ -571,7 +571,8 @@ constructor( !questionnaireConfig.resourceIdentifier.isNullOrEmpty() && subjectType != null ) { - searchQuestionnaireResponse( + defaultRepository + .searchQuestionnaireResponse( resourceId = questionnaireConfig.resourceIdentifier!!, resourceType = questionnaireConfig.resourceType ?: subjectType, questionnaireId = questionnaire.logicalId, @@ -1064,48 +1065,6 @@ constructor( } } - /** - * 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 }, - ) - filter( - QuestionnaireResponse.QUESTIONNAIRE, - { value = questionnaireId.asReference(ResourceType.Questionnaire).reference }, - ) - if (!encounterId.isNullOrBlank()) { - filter( - QuestionnaireResponse.ENCOUNTER, - { - value = - encounterId.extractLogicalIdUuid().asReference(ResourceType.Encounter).reference - }, - ) - } - 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?, @@ -1176,7 +1135,8 @@ constructor( questionnaireConfig.isReadOnly() || questionnaireConfig.saveDraft) ) { - searchQuestionnaireResponse( + defaultRepository + .searchQuestionnaireResponse( resourceId = resourceIdentifier, resourceType = resourceType, questionnaireId = questionnaire.logicalId, @@ -1186,6 +1146,7 @@ constructor( ?.let { QuestionnaireResponse().apply { id = it.id + status = it.status item = it.item.removeUnAnsweredItems() // Clearing the text prompts the SDK to re-process the content, which includes HTML clearText() diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt index 19e1e44dbf..756274dcbe 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/util/extensions/ConfigExtensions.kt @@ -236,6 +236,15 @@ fun ActionConfig.handleClickEvent( val appCompatActivity = (navController.context as AppCompatActivity) PdfLauncherFragment.launch(appCompatActivity, interpolatedPdfConfig.encodeJson()) } + ApplicationWorkflow.DELETE_DRAFT_QUESTIONNAIRE -> { + val questionnaireConfigInterpolated = + actionConfig.questionnaire?.interpolate(computedValuesMap) + val args = + bundleOf( + NavigationArg.QUESTIONNAIRE_CONFIG to questionnaireConfigInterpolated, + ) + navController.navigate(MainNavigationScreen.AlertDialogFragment.route, args) + } else -> return } } diff --git a/android/quest/src/main/res/navigation/application_nav_graph.xml b/android/quest/src/main/res/navigation/application_nav_graph.xml index 21bf25e2a1..3c77f01c8e 100644 --- a/android/quest/src/main/res/navigation/application_nav_graph.xml +++ b/android/quest/src/main/res/navigation/application_nav_graph.xml @@ -110,4 +110,12 @@ android:name="org.smartregister.fhircore.quest.ui.bottomsheet.SummaryBottomSheetFragment" > + + + diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModelTest.kt new file mode 100644 index 0000000000..14fbdcfae7 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireDraftDialogViewModelTest.kt @@ -0,0 +1,197 @@ +/* + * 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.questionnaire + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import ca.uhn.fhir.parser.IParser +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.get +import com.google.android.fhir.search.Search +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.mockk +import io.mockk.spyk +import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.hl7.fhir.r4.model.AuditEvent +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.ResourceType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.smartregister.fhircore.engine.configuration.QuestionnaireConfig +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.rulesengine.ConfigRulesExecutor +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.asReference +import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor +import org.smartregister.fhircore.quest.app.fakes.Faker +import org.smartregister.fhircore.quest.robolectric.RobolectricTest +import org.smartregister.fhircore.quest.ui.questionnaire.QuestionnaireDraftDialogViewModel.Companion.AUDIT_EVENT_CODE +import org.smartregister.fhircore.quest.ui.questionnaire.QuestionnaireDraftDialogViewModel.Companion.AUDIT_EVENT_DISPLAY +import org.smartregister.fhircore.quest.ui.questionnaire.QuestionnaireDraftDialogViewModel.Companion.AUDIT_EVENT_SYSTEM + +@HiltAndroidTest +class QuestionnaireDraftDialogViewModelTest : RobolectricTest() { + + @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) + + @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper + + @Inject lateinit var configService: ConfigService + + @Inject lateinit var resourceDataRulesExecutor: ResourceDataRulesExecutor + + @Inject lateinit var fhirPathDataExtractor: FhirPathDataExtractor + + @Inject lateinit var fhirEngine: FhirEngine + + @Inject lateinit var dispatcherProvider: DispatcherProvider + + @Inject lateinit var parser: IParser + + @Inject lateinit var contentCache: ContentCache + + private lateinit var questionnaireDraftDialogViewModel: QuestionnaireDraftDialogViewModel + lateinit var defaultRepository: DefaultRepository + private val configurationRegistry = Faker.buildTestConfigurationRegistry() + private val context: Application = ApplicationProvider.getApplicationContext() + private val configRulesExecutor: ConfigRulesExecutor = mockk() + lateinit var questionnaireConfig: QuestionnaireConfig + lateinit var questionnaireResponse: QuestionnaireResponse + private val practitionerId = "practitioner-id-1" + + @Before + @ExperimentalCoroutinesApi + fun setUp() { + hiltRule.inject() + // Write practitioner and organization to shared preferences + sharedPreferencesHelper.write( + SharedPreferenceKey.PRACTITIONER_ID.name, + practitionerId, + ) + defaultRepository = + spyk( + DefaultRepository( + fhirEngine = fhirEngine, + dispatcherProvider = dispatcherProvider, + sharedPreferencesHelper = sharedPreferencesHelper, + configurationRegistry = configurationRegistry, + configService = configService, + configRulesExecutor = configRulesExecutor, + fhirPathDataExtractor = fhirPathDataExtractor, + parser = parser, + context = context, + contentCache = contentCache, + ), + ) + questionnaireDraftDialogViewModel = + spyk( + QuestionnaireDraftDialogViewModel( + defaultRepository = defaultRepository, + sharedPreferencesHelper = sharedPreferencesHelper, + ), + ) + + questionnaireConfig = + QuestionnaireConfig( + id = "dc-clinic-medicines", + resourceType = ResourceType.Patient, + resourceIdentifier = "Patient-id-1", + ) + questionnaireResponse = + QuestionnaireResponse().apply { + id = "qr-id-1" + status = QuestionnaireResponse.QuestionnaireResponseStatus.INPROGRESS + subject = "Patient-id-1".asReference(ResourceType.Patient) + questionnaire = "Questionnaire/dc-clinic-medicines" + } + } + + @Test + fun testDeleteDraftUpdateQuestionnaireResponseStatusToStoppedAndAuditEvent() { + runTest(timeout = 90.seconds) { + // add QR to db + fhirEngine.create(questionnaireResponse) + val savedDraft = fhirEngine.get("qr-id-1") + assertEquals("QuestionnaireResponse/qr-id-1", savedDraft.id) + assertEquals("Patient/Patient-id-1", savedDraft.subject.reference) + assertEquals("Questionnaire/dc-clinic-medicines", savedDraft.questionnaire) + assertEquals("in-progress", savedDraft.status.toCode()) + + runBlocking { + questionnaireDraftDialogViewModel.deleteDraft(questionnaireConfig = questionnaireConfig) + } + + val deletedDraft = fhirEngine.get("qr-id-1") + assertEquals("QuestionnaireResponse/qr-id-1", deletedDraft.id) + assertEquals("Patient/Patient-id-1", deletedDraft.subject.reference) + assertEquals("Questionnaire/dc-clinic-medicines", deletedDraft.questionnaire) + assertEquals("stopped", deletedDraft.status.toCode()) + + val search = + Search(ResourceType.AuditEvent).apply { + filter( + AuditEvent.SOURCE, + { value = "Patient-id-1".asReference(ResourceType.Patient).reference }, + ) + filter( + AuditEvent.TYPE, + { value = of("delete_draft") }, + ) + } + + val createdAuditEventList = defaultRepository.search(search) + assertNotNull(createdAuditEventList) + assertEquals( + "QuestionnaireResponse/qr-id-1", + createdAuditEventList[0].entity[0].what.reference, + ) + assertEquals( + "Practitioner/practitioner-id-1", + createdAuditEventList[0].agent[0].who.reference, + ) + assertEquals("Patient/Patient-id-1", createdAuditEventList[0].source.observer.reference) + } + } + + @Test + fun testCreateDeleteDraftFlag() { + val auditEvent = + questionnaireDraftDialogViewModel.createDeleteDraftAuditEvent( + questionnaireConfig = questionnaireConfig, + questionnaireResponse = questionnaireResponse, + ) + + assertEquals("Patient/Patient-id-1", auditEvent.source.observer.reference) + assertEquals("Practitioner/practitioner-id-1", auditEvent.agent[0].who.reference) + assertEquals(AUDIT_EVENT_SYSTEM, auditEvent.type.system) + assertEquals(AUDIT_EVENT_CODE, auditEvent.type.code) + assertEquals(AUDIT_EVENT_DISPLAY, auditEvent.type.display) + } +} 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 2360f2061c..e2b95eaaa4 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 @@ -1375,102 +1375,6 @@ class QuestionnaireViewModelTest : RobolectricTest() { coVerify { defaultRepository.addOrUpdate(true, patient) } } - @Test - fun testSearchLatestQuestionnaireResponseShouldReturnLatestQuestionnaireResponse() = - runTest(timeout = 90.seconds) { - Assert.assertNull( - questionnaireViewModel.searchQuestionnaireResponse( - resourceId = patient.logicalId, - resourceType = ResourceType.Patient, - questionnaireId = questionnaireConfig.id, - encounterId = null, - ), - ) - - val questionnaireResponses = - listOf( - QuestionnaireResponse().apply { - id = "qr1" - meta.lastUpdated = Date() - subject = patient.asReference() - questionnaire = samplePatientRegisterQuestionnaire.asReference().reference - }, - QuestionnaireResponse().apply { - id = "qr2" - meta.lastUpdated = yesterday() - subject = patient.asReference() - questionnaire = samplePatientRegisterQuestionnaire.asReference().reference - }, - ) - - // 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, - ) - Assert.assertNotNull(latestQuestionnaireResponse) - 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" @@ -1628,7 +1532,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { } coEvery { - questionnaireViewModel.searchQuestionnaireResponse( + defaultRepository.searchQuestionnaireResponse( resourceId = patient.logicalId, resourceType = ResourceType.Patient, questionnaireId = questionnaireConfig.id, diff --git a/docs/engineering/app/configuring/forms/save-form-as-draft.mdx b/docs/engineering/app/configuring/forms/save-form-as-draft.mdx index c9d33123c4..a6457a835b 100644 --- a/docs/engineering/app/configuring/forms/save-form-as-draft.mdx +++ b/docs/engineering/app/configuring/forms/save-form-as-draft.mdx @@ -16,19 +16,25 @@ This excludes forms such as "register client" or "register household". - 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 save draft functionality can be configured using the `LAUNCH_QUESTIONNAIRE` or the `DELETE_DRAFT_QUESTIONNAIRE` workflow. 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 - } + "actions": [ + { + "trigger": "ON_CLICK", + "workflow": "LAUNCH_QUESTIONNAIRE", + "questionnaire": { + "id": "add-family-member", + "title": "Add Family Member", + "resourceIdentifier": "sample-house-id", + "resourceType": "Group", + "saveDraft": true + } + } + ] } ``` ## Config properties @@ -41,15 +47,107 @@ 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 +### UI/UX workflow for saving a form as draft 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 +#### 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 +Cancel | Dismisses the dialog so that the user can continue interacting with the form | + +## Launching save draft from DELETE_DRAFT_QUESTIONNAIRE workflow +The save draft functionality works the same as described above when launched using the `DELETE_DRAFT_QUESTIONNAIRE` workflow. +The workflow adds another dialog that allows the user to either open or delete the draft. +The sample below demonstrates the configs that are required in order to save a form as a draft and also delete the draft. +```json +{ + "actions": [ + { + "trigger": "ON_CLICK", + "workflow": "DELETE_DRAFT_QUESTIONNAIRE", + "questionnaire": { + "id": "add-family-member", + "title": "Add Family Member", + "resourceIdentifier": "sample-house-id", + "resourceType": "Group", + "saveDraft": true + } + } + ] +} +``` + +### UI/UX workflow for deleting a draft form +When the `DELETE_DRAFT_QUESTIONNAIRE` workflow is configured, a dialog appears when the call to action is triggered. +The dialog has 3 buttons i.e `Open draft`, `Delete draft` and `Cancel`. + +The table below details what each of the buttons does. + +|Button | Description | +|:--|:--| +Open draft | Opens the questionnaire pre-filled with the saved draft changes | +Delete draft | Does a soft delete of the draft i.e update the status of the `QuestionnaireResponse` to `stopped` | +Cancel | Dismisses the dialog | + +### Propagating deletes to other devices +Since the devices work offline, there is a chance that a draft that has been deleted on device A could have some local changes on a device B. +Due to the way conflict resolution works, at the moment, when device B syncs the changes that indicate the draft has been deleted will not reflect on device B. +With this in mind, Event Management is used to update the deleted drafts in the background. + +The following is a sample config that would be added to the `application_config.json` +``` +{ + "eventWorkflows": [ + { + "eventType": "RESOURCE_CLOSURE", + "triggerConditions": [ + ], + "eventResources": [ + { + "id": "draftFormToBeClosed", + "resource": "AuditEvent", + "dataQueries": [ + { + "paramName": "type", + "filterCriteria": [ + { + "dataType": "CODE", + "value": { + "system": "http://smartregister.org/", + "code": "delete_draft" + } + } + ] + } + ], + "relatedResources": [ + { + "resource": "QuestionnaireResponse", + "searchParameter": "entity", + "isRevInclude": false + } + ] + } + ], + "updateValues": [ + { + "jsonPathExpression": "QuestionnaireResponse.status", + "value": "stopped", + "resourceType": "QuestionnaireResponse" + } + ], + "resourceFilterExpressions": [] + } + ] +} +``` + +An `AuditEvent` resource is used to keep track of deleted drafts. It has a reference to the `QuestionnaireResponse` in the `entity` field. +The event management functionality fetches all the `AuditEvents` that have the `type` = `delete_draft`. +Then fetches the related `QuestionnaireResponses` by doing a forward include search on the `QuestionnaireResponse.entity` field. +The status for the retrieved `QuestionnaireResponses` is then updated to `stopped` i.e the draft is soft deleted. \ No newline at end of file