Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Save draft MVP functionality #3596

Merged
merged 14 commits into from
Nov 13, 2024
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AlertDialogListItem>? = null,
): AlertDialog {
Expand All @@ -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()
Expand Down Expand Up @@ -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<AlertDialogListItem>? = null,
): AlertDialog {
Expand All @@ -184,6 +191,8 @@ object AlertDialogue {
confirmButtonText = confirmButtonText,
neutralButtonListener = neutralButtonListener,
neutralButtonText = neutralButtonText,
negativeButtonListener = negativeButtonListener,
negativeButtonText = negativeButtonText,
cancellable = cancellable,
options = options?.toTypedArray(),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
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
Expand Down Expand Up @@ -292,3 +293,19 @@
}
}
}

/**
* 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()

Check warning on line 307 in android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt

View check run for this annotation

Codecov / codecov/patch

android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt#L307

Added line #L307 was not covered by tests
} else {
null

Check warning on line 309 in android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt

View check run for this annotation

Codecov / codecov/patch

android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/QuestionnaireExtension.kt#L309

Added line #L309 was not covered by tests
}
}
11 changes: 7 additions & 4 deletions android/engine/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,12 @@
<string name="error_saving_form">Error encountered cannot save form</string>
<string name="form_progress_message">Processing data. Please wait</string>
<string name="questionnaire_alert_back_pressed_message">Are you sure you want to go back?</string>
<string name="questionnaire_in_progress_alert_back_pressed_message">Are you sure you want to discard the answers?</string>
<string name="questionnaire_alert_back_pressed_title">Discard changes</string>
<string name="questionnaire_alert_back_pressed_button_title">Discard</string>
<string name="questionnaire_alert_back_pressed_save_draft_button_title">Save partial draft</string>
<string name="questionnaire_in_progress_alert_back_pressed_message">If you leave without saving, all your changes will not be saved</string>
<string name="questionnaire_alert_back_pressed_title">You have unsaved changes</string>
<string name="questionnaire_alert_back_pressed_button_title">Discard changes</string>
<string name="questionnaire_alert_back_pressed_save_draft_button_title">Save as draft</string>
<string name="questionnaire_alert_neutral_button_title">Cancel</string>
<string name="questionnaire_alert_negative_button_title">Discard Changes</string>
<string name="questionnaire_alert_confirm_button_title">Yes</string>
<string name="questionnaire_alert_invalid_message">Given details have validation errors. Resolve errors and submit again</string>
<string name="questionnaire_alert_invalid_title">Validation Failed</string>
Expand Down Expand Up @@ -198,4 +199,6 @@
<string name="unsynced_data_present">There\'s some un-synced data</string>
<string name="missing_supervisor_contact">Supervisor contact missing or the provided phone number is invalid</string>
<string name="apply_filter">APPLY FILTER</string>
<string name="questionnaire_save_draft_title">Save draft changes</string>
<string name="questionnaire_save_draft_message">Do you want to save draft changes?</string>
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -366,16 +366,20 @@
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 = {},

Check warning on line 377 in android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt

View check run for this annotation

Codecov / codecov/patch

android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt#L377

Added line #L377 was not covered by tests
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() },

Check warning on line 380 in android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt

View check run for this annotation

Codecov / codecov/patch

android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt#L379-L380

Added lines #L379 - L380 were not covered by tests
negativeButtonText =
org.smartregister.fhircore.engine.R.string.questionnaire_alert_negative_button_title,

Check warning on line 382 in android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt

View check run for this annotation

Codecov / codecov/patch

android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireActivity.kt#L382

Added line #L382 was not covered by tests
)
} else {
AlertDialogue.showConfirmAlert(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
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
Expand Down Expand Up @@ -544,6 +545,7 @@
resourceType = questionnaireConfig.resourceType ?: subjectType,
questionnaireId = questionnaire.logicalId,
encounterId = questionnaireConfig.encounterId,
questionnaireResponseStatus = questionnaireConfig.questionnaireResponseStatus(),

Check warning on line 548 in android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt

View check run for this annotation

Codecov / codecov/patch

android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt#L548

Added line #L548 was not covered by tests
)
?.contained
?.asSequence()
Expand Down Expand Up @@ -672,12 +674,35 @@
* This function saves [QuestionnaireResponse] as draft if any of the [QuestionnaireResponse.item]
* has an answer.
*/
fun saveDraftQuestionnaire(questionnaireResponse: QuestionnaireResponse) {
fun saveDraftQuestionnaire(
questionnaireResponse: QuestionnaireResponse,
questionnaireConfig: QuestionnaireConfig,
) {
viewModelScope.launch {
val hasPages = questionnaireResponse.item.any { it.hasItem() }
val questionnaireHasAnswer =
questionnaireResponse.item.any {
it.answer.any { answerComponent -> answerComponent.hasValue() }
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

Check warning on line 696 in android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt

View check run for this annotation

Codecov / codecov/patch

android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt#L695-L696

Added lines #L695 - L696 were not covered by tests
if (
!questionnaireConfig.resourceIdentifier.isNullOrBlank() &&
questionnaireConfig.resourceType != null
) {
questionnaireResponse.subject =
questionnaireConfig.resourceIdentifier!!.asReference(
questionnaireConfig.resourceType!!,

Check warning on line 703 in android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt

View check run for this annotation

Codecov / codecov/patch

android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt#L701-L703

Added lines #L701 - L703 were not covered by tests
)
}
if (questionnaireHasAnswer) {
questionnaireResponse.status = QuestionnaireResponse.QuestionnaireResponseStatus.INPROGRESS
defaultRepository.addOrUpdate(
Expand Down Expand Up @@ -1011,6 +1036,7 @@
resourceType: ResourceType,
questionnaireId: String,
encounterId: String?,
questionnaireResponseStatus: String? = null,

Check warning on line 1039 in android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt

View check run for this annotation

Codecov / codecov/patch

android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt#L1039

Added line #L1039 was not covered by tests
): QuestionnaireResponse? {
val search =
Search(ResourceType.QuestionnaireResponse).apply {
Expand All @@ -1031,6 +1057,12 @@
},
)
}
if (!questionnaireResponseStatus.isNullOrBlank()) {
filter(
QuestionnaireResponse.STATUS,

Check warning on line 1062 in android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt

View check run for this annotation

Codecov / codecov/patch

android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt#L1061-L1062

Added lines #L1061 - L1062 were not covered by tests
{ value = of(questionnaireResponseStatus) },
)
}
}
val questionnaireResponses: List<QuestionnaireResponse> = defaultRepository.search(search)
return questionnaireResponses.maxByOrNull { it.meta.lastUpdated }
Expand Down Expand Up @@ -1100,13 +1132,16 @@
if (
resourceType != null &&
!resourceIdentifier.isNullOrEmpty() &&
(questionnaireConfig.isEditable() || questionnaireConfig.isReadOnly())
(questionnaireConfig.isEditable() ||
questionnaireConfig.isReadOnly() ||
questionnaireConfig.saveDraft)
) {
searchQuestionnaireResponse(
resourceId = resourceIdentifier,
resourceType = resourceType,
questionnaireId = questionnaire.logicalId,
encounterId = questionnaireConfig.encounterId,
questionnaireResponseStatus = questionnaireConfig.questionnaireResponseStatus(),

Check warning on line 1144 in android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt

View check run for this annotation

Codecov / codecov/patch

android/quest/src/main/java/org/smartregister/fhircore/quest/ui/questionnaire/QuestionnaireViewModel.kt#L1144

Added line #L1144 was not covered by tests
)
?.let {
QuestionnaireResponse().apply {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ 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
Expand Down Expand Up @@ -734,14 +735,49 @@ class QuestionnaireViewModelTest : RobolectricTest() {
},
)
}
questionnaireViewModel.saveDraftQuestionnaire(questionnaireResponse)
questionnaireViewModel.saveDraftQuestionnaire(
questionnaireResponse,
QuestionnaireConfig("qr-id-1"),
)
Assert.assertEquals(
QuestionnaireResponse.QuestionnaireResponseStatus.INPROGRESS,
questionnaireResponse.status,
)
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 testUpdateResourcesLastUpdatedProperty() = runTest {
val yesterday = yesterday()
Expand Down Expand Up @@ -1445,6 +1481,7 @@ class QuestionnaireViewModelTest : RobolectricTest() {
}
listResource.addEntry(listEntryComponent)
addContained(listResource)
status = QuestionnaireResponse.QuestionnaireResponseStatus.COMPLETED
}

coEvery {
Expand All @@ -1453,6 +1490,7 @@ class QuestionnaireViewModelTest : RobolectricTest() {
resourceType = ResourceType.Patient,
questionnaireId = questionnaireConfig.id,
encounterId = null,
questionnaireResponseStatus = questionnaireConfig.questionnaireResponseStatus(),
)
} returns previousQuestionnaireResponse

Expand Down
55 changes: 55 additions & 0 deletions docs/engineering/app/configuring/forms/save-form-as-draft.mdx
Original file line number Diff line number Diff line change
@@ -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 |
Loading