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

Add bulk finalization #5734

Merged
merged 46 commits into from
Oct 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
932362f
Spike out use cases for form entry loading and init
seadowg Sep 5, 2023
1f7917a
Spike out use case for finalizing a draft
seadowg Sep 5, 2023
a229383
Improve interface for use with existing loading/saving code
seadowg Sep 6, 2023
104fc93
Allow all instances to be finalized in one click
seadowg Sep 6, 2023
764a185
Use InstancesAppState to update drafts list
seadowg Sep 7, 2023
90865b7
Only check for hardcoded text on CI
seadowg Sep 7, 2023
fc52e7f
Rename InstancesAppState to be inline with FormsDataService
seadowg Sep 7, 2023
9ef6db9
Use AppState in InstancesDataService
seadowg Sep 7, 2023
3abf938
Move bulk finalization logic to data service
seadowg Sep 7, 2023
1f61d24
Remove option to disable predicate caching
seadowg Sep 7, 2023
d5b33d3
Construct FormEntryController factory in data service
seadowg Sep 7, 2023
3836354
Allow EntitiesRepository to be provided for the current project
seadowg Sep 11, 2023
96fc2e1
Remove CurrentProjectProvider dependenccy from InstancesDataService
seadowg Sep 11, 2023
279bc9e
Remove Context dependency from InstancesDataService
seadowg Sep 11, 2023
b8a3003
Only finalize drafts
seadowg Sep 11, 2023
599d87b
Show progress dialog when finalizing forms
seadowg Sep 11, 2023
e95aaa3
Show different snackbar when form finalization fails
seadowg Sep 11, 2023
58e8f09
Show instance in different state when validation has failed
seadowg Sep 11, 2023
19d5479
Allow editing invalid drafts
seadowg Sep 11, 2023
6721f7a
Don't revalidate invalid forms
seadowg Sep 11, 2023
9d45a71
Add test for partial submissions
seadowg Sep 12, 2023
24a9b26
Use real FormController to test FormEntryUseCases
seadowg Sep 13, 2023
03579b9
Add operations that could be used in real code to FormEntryUseCases
seadowg Sep 13, 2023
dd5bf1c
Handle partial forms in finalizeDraft
seadowg Sep 13, 2023
ee2af36
Rename method
seadowg Sep 13, 2023
459b1f5
Media dir shouldn't be shared
seadowg Sep 13, 2023
89f217f
Allow reference manager to be setup without a StoragePathProvider
seadowg Sep 13, 2023
5f881ff
Update instance name when bulk finalizing
seadowg Sep 14, 2023
8e4aeca
Add string resource
seadowg Sep 14, 2023
b0a503a
Add other missing string resources
seadowg Sep 14, 2023
d07d8c9
Use translated strings in tests
seadowg Sep 14, 2023
f87b801
Use overload in implementation
seadowg Sep 26, 2023
82f7b8b
Use quantity strings for finalization success and failure strings
seadowg Sep 26, 2023
f95ef93
Make test name as IntelliJ expects
seadowg Sep 27, 2023
01a1194
Use same string for saved and invalid forms
seadowg Sep 27, 2023
3df8d43
Use DatabaseObjectMapper in adapter
seadowg Sep 27, 2023
3cbb763
Pull instance list item view setup code out
seadowg Sep 27, 2023
75a1a63
Fix nullable values
seadowg Sep 27, 2023
6097ed4
Add incomplete chip to invalid drafts
seadowg Sep 27, 2023
e17609e
Add basic styling to chip
seadowg Sep 27, 2023
545c82d
Show incomplete chip for incomplete drafts
seadowg Sep 27, 2023
e35d2f5
Add icon to incomplete chip
seadowg Sep 27, 2023
415e8b7
Add string for incomplete
seadowg Sep 27, 2023
df74a3f
Pull out constant for disabled value
seadowg Sep 29, 2023
cdd3745
Add deprecation notice to method that should be replaced
seadowg Sep 29, 2023
6dc15fd
Fix quantity strings
seadowg Oct 2, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ jobs:

- run:
name: Run code quality checks
command: ./gradlew checkCode
command: ./gradlew pmd ktlintCheck checkstyle lintDebug -PlintStrings

test_modules:
<<: *android_config
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import android.app.Application
import android.app.Service
import android.content.Context
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel

/**
Expand Down Expand Up @@ -41,13 +43,21 @@ class AppState {

@Suppress("UNCHECKED_CAST")
fun <T> get(key: String): T? {
return map.get(key) as T?
return map[key] as T?
}

fun <T> getLive(key: String, default: T): LiveData<T> {
return get(key, MutableLiveData(default))
}

fun set(key: String, value: Any?) {
map[key] = value
}

fun <T> setLive(key: String, value: T?) {
get(key, MutableLiveData<T>()).postValue(value)
}

fun clear() {
map.clear()
}
Expand Down
5 changes: 5 additions & 0 deletions collect_app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,10 @@ android {
htmlReport true
lintConfig file("$rootDir/config/lint.xml")
xmlReport true

if (!project.hasProperty("lintStrings")) {
disable += ["HardcodedText"]
}
}
namespace 'org.odk.collect.android'
}
Expand Down Expand Up @@ -373,6 +377,7 @@ dependencies {
exclude group: 'org.robolectric' // Some tests in `collect_app` don't work with newer Robolectric
}
testImplementation(project(":shadows"))
testImplementation(project(":test-forms"))

testImplementation Dependencies.robolectric

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package org.odk.collect.android.feature.formmanagement

import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.odk.collect.android.support.pages.FormEntryPage.QuestionAndAnswer
import org.odk.collect.android.support.pages.MainMenuPage
import org.odk.collect.android.support.pages.SaveOrDiscardFormDialog
import org.odk.collect.android.support.rules.CollectTestRule
import org.odk.collect.android.support.rules.TestRuleChain
import org.odk.collect.strings.R.plurals
import org.odk.collect.strings.R.string

@RunWith(AndroidJUnit4::class)
class BulkFinalizationTest {

val rule = CollectTestRule()

@get:Rule
val chain: RuleChain = TestRuleChain.chain().around(rule)

@Test
fun canBulkFinalizeDrafts() {
rule.startAtMainMenu()
.copyForm("one-question.xml")
.startBlankForm("One Question")
.fillOutAndSave(QuestionAndAnswer("what is your age", "97"))
.startBlankForm("One Question")
.fillOutAndSave(QuestionAndAnswer("what is your age", "98"))

.clickEditSavedForm(2)
.clickOptionsIcon(string.finalize_all_forms)
.clickOnString(string.finalize_all_forms)
.checkIsSnackbarWithQuantityDisplayed(plurals.bulk_finalize_success, 2)
.assertTextDoesNotExist("One Question")
.pressBack(MainMenuPage())

.assertNumberOfFinalizedForms(2)
}

@Test
fun whenThereAreDraftsWithConstraintViolations_marksFormsAsHavingErrors() {
rule.startAtMainMenu()
.copyForm("two-question-required.xml")
.startBlankForm("Two Question Required")
.fillOut(QuestionAndAnswer("What is your name?", "Dan"))
.pressBack(SaveOrDiscardFormDialog(MainMenuPage()))
.clickSaveChanges()

.startBlankForm("Two Question Required")
.fillOutAndSave(
QuestionAndAnswer("What is your name?", "Tim"),
QuestionAndAnswer("What is your age?", "45", true)
)

.clickEditSavedForm(2)
.clickOptionsIcon(string.finalize_all_forms)
.clickOnString(string.finalize_all_forms)
.checkIsSnackbarWithMessageDisplayed(string.bulk_finalize_partial_success, 1, 1)
.assertText("Two Question Required")
.pressBack(MainMenuPage())

.assertNumberOfEditableForms(1)
.assertNumberOfFinalizedForms(1)
}

@Test
fun whenADraftPreviouslyHadConstraintViolations_marksFormsAsHavingErrors() {
rule.startAtMainMenu()
.copyForm("two-question-required.xml")
.startBlankForm("Two Question Required")
.fillOut(QuestionAndAnswer("What is your name?", "Dan"))
.pressBack(SaveOrDiscardFormDialog(MainMenuPage()))
.clickSaveChanges()

.clickEditSavedForm(1)
.clickOptionsIcon(string.finalize_all_forms)
.clickOnString(string.finalize_all_forms)
.checkIsSnackbarWithQuantityDisplayed(plurals.bulk_finalize_failure, 1)

.clickOptionsIcon(string.finalize_all_forms)
.clickOnString(string.finalize_all_forms)
.checkIsSnackbarWithQuantityDisplayed(plurals.bulk_finalize_failure, 1)
}

@Test
fun doesNotFinalizeOtherTypesOfInstance() {
rule.startAtMainMenu()
.copyForm("one-question.xml")
.startBlankForm("One Question")
.fillOutAndSave(QuestionAndAnswer("what is your age", "97"))
.startBlankForm("One Question")
.fillOutAndFinalize(QuestionAndAnswer("what is your age", "98"))

.clickEditSavedForm(1)
.clickOptionsIcon(string.finalize_all_forms)
.clickOnString(string.finalize_all_forms)
.checkIsSnackbarWithQuantityDisplayed(plurals.bulk_finalize_success, 1)
.assertTextDoesNotExist("One Question")
.pressBack(MainMenuPage())

.assertNumberOfFinalizedForms(2)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package org.odk.collect.android.feature.instancemanagement

import androidx.test.ext.junit.runners.AndroidJUnit4
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.Rule
import org.junit.Test
import org.junit.rules.RuleChain
import org.junit.runner.RunWith
import org.kxml2.io.KXmlParser
import org.kxml2.kdom.Document
import org.odk.collect.android.support.TestDependencies
import org.odk.collect.android.support.pages.FormEntryPage
import org.odk.collect.android.support.rules.CollectTestRule
import org.odk.collect.android.support.rules.TestRuleChain
import java.io.File
import java.io.StringReader

@RunWith(AndroidJUnit4::class)
class PartialSubmissionTet {

private val testDependencies = TestDependencies()
private val rule = CollectTestRule(useDemoProject = false)

@get:Rule
val chain: RuleChain = TestRuleChain.chain(testDependencies)
.around(rule)

@Test
fun canFillAndSubmitAFormWithPartialSubmission() {
rule.withProject(testDependencies.server.url)
.copyForm("one-question-partial.xml", testDependencies.server.hostName)
.startBlankForm("One Question")
.fillOutAndFinalize(FormEntryPage.QuestionAndAnswer("what is your age", "123"))

.clickSendFinalizedForm(1)
.clickSelectAll()
.clickSendSelected()

val submissions = testDependencies.server.submissions
assertThat(submissions.size, equalTo(1))

val root = parseXml(submissions[0]).rootElement
assertThat(root.name, equalTo("age"))
assertThat(root.childCount, equalTo(1))
assertThat(root.getChild(0), equalTo("123"))
}

private fun parseXml(file: File): Document {
return StringReader(String(file.readBytes())).use { reader ->
val parser = KXmlParser()
parser.setInput(reader)
Document().also { it.parse(parser) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import org.odk.collect.android.openrosa.HttpPostResult;
import org.odk.collect.android.openrosa.OpenRosaConstants;
import org.odk.collect.android.openrosa.OpenRosaHttpInterface;
import org.odk.collect.android.utilities.FileUtils;
import org.odk.collect.shared.TempFiles;
import org.odk.collect.shared.strings.Md5;
import org.odk.collect.shared.strings.RandomString;

Expand Down Expand Up @@ -44,6 +46,8 @@ public class StubOpenRosaServer implements OpenRosaHttpInterface {
private boolean noHashPrefixInMediaFiles;
private boolean randomHash;

private final File submittedFormsDir = TempFiles.createTempDir();

@NonNull
@Override
public HttpGetResult executeGetRequest(@NonNull URI uri, @Nullable String contentType, @Nullable HttpCredentialsInterface credentials) throws Exception {
Expand Down Expand Up @@ -111,6 +115,8 @@ public HttpPostResult uploadSubmissionAndFiles(@NonNull File submissionFile, @No
} else if (credentialsIncorrect(credentials)) {
return new HttpPostResult("", 401, "");
} else if (uri.getPath().equals(OpenRosaConstants.SUBMISSION)) {
File destFile = new File(submittedFormsDir, String.valueOf(submittedFormsDir.listFiles().length));
FileUtils.copyFile(submissionFile, destFile);
return new HttpPostResult("", 201, "");
} else {
return new HttpPostResult("", 404, "");
Expand Down Expand Up @@ -162,6 +168,10 @@ public String getHostName() {
return HOST;
}

public List<File> getSubmissions() {
return asList(submittedFormsDir.listFiles());
}

private boolean credentialsIncorrect(HttpCredentialsInterface credentials) {
if (username == null && password == null) {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ public <D extends Page<D>> D fillOutAndSave(D destination, QuestionAndAnswer...
.clickSaveChanges();
}

public MainMenuPage fillOutAndSave(QuestionAndAnswer... questionsAndAnswers) {
return fillOut(questionsAndAnswers)
.pressBack(new SaveOrDiscardFormDialog<>(new MainMenuPage()))
.clickSaveChanges();
}

public MainMenuPage fillOutAndFinalize(QuestionAndAnswer... questionsAndAnswers) {
return fillOut(questionsAndAnswers)
.swipeToEndScreen()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.odk.collect.android.support.pages

import android.app.Application
import android.content.pm.ActivityInfo
import androidx.annotation.StringRes
import androidx.recyclerview.widget.RecyclerView
Expand Down Expand Up @@ -43,6 +44,7 @@ import org.odk.collect.android.support.WaitFor.waitFor
import org.odk.collect.android.support.actions.RotateAction
import org.odk.collect.android.support.matchers.CustomMatchers.withIndex
import org.odk.collect.androidshared.ui.ToastUtils.popRecordedToasts
import org.odk.collect.strings.localization.getLocalizedQuantityString
import org.odk.collect.strings.localization.getLocalizedString
import org.odk.collect.testshared.RecyclerViewMatcher
import timber.log.Timber
Expand Down Expand Up @@ -169,7 +171,18 @@ abstract class Page<T : Page<T>> {
return this as T
}

fun checkIsSnackbarWithMessageDisplayed(message: Int): T {
fun checkIsSnackbarWithQuantityDisplayed(message: Int, quantity: Int): T {
return checkIsSnackbarWithMessageDisplayed(
ApplicationProvider.getApplicationContext<Application>()
.getLocalizedQuantityString(message, quantity, quantity)
)
}

fun checkIsSnackbarWithMessageDisplayed(message: Int, vararg formatArgs: Any): T {
return checkIsSnackbarWithMessageDisplayed(getTranslatedString(message, *formatArgs))
}

fun checkIsSnackbarWithMessageDisplayed(message: String): T {
seadowg marked this conversation as resolved.
Show resolved Hide resolved
onView(withText(message)).check(matches(withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE)))
return this as T
}
Expand Down Expand Up @@ -425,6 +438,10 @@ abstract class Page<T : Page<T>> {
}

fun clickOptionsIcon(@StringRes expectedOptionString: Int): T {
return clickOptionsIcon(getTranslatedString(expectedOptionString))
}

fun clickOptionsIcon(expectedOptionString: String): T {
tryAgainOnFail({
Espresso.openActionBarOverflowOrOptionsMenu(ActivityHelpers.getActivity())
assertText(expectedOptionString)
Expand Down
Loading