Use vvalidator in the add site page

This commit is contained in:
Aidan Follestad 2019-01-26 13:55:34 -08:00
parent 7dc4ee7fb1
commit b3f8a43f71
10 changed files with 79 additions and 400 deletions

View file

@ -55,7 +55,6 @@ dependencies {
// afollestad
implementation 'com.afollestad.material-dialogs:core:' + versions.materialDialogs
implementation 'com.afollestad:vvalidator:' + versions.vvalidator
// Debugging
implementation 'com.jakewharton.timber:timber:' + versions.timber

View file

@ -31,11 +31,13 @@ import com.afollestad.nocknock.utilities.ext.onTextChanged
import com.afollestad.nocknock.utilities.ext.setTextAndMaintainSelection
import com.afollestad.nocknock.utilities.livedata.distinct
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition
import com.afollestad.nocknock.viewcomponents.ext.onScroll
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.toViewError
import com.afollestad.nocknock.viewcomponents.livedata.toViewText
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import com.afollestad.vvalidator.form
import com.afollestad.vvalidator.form.Form
import kotlinx.android.synthetic.main.activity_addsite.checkIntervalLayout
import kotlinx.android.synthetic.main.activity_addsite.headersLayout
import kotlinx.android.synthetic.main.activity_addsite.inputName
@ -64,13 +66,15 @@ class AddSiteActivity : DarkModeSwitchActivity() {
}
private val viewModel by viewModel<AddSiteViewModel>()
private lateinit var validationForm: Form
@SuppressLint("SetTextI18n")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_addsite)
setupUi()
setupUi()
setupValidation()
lifecycle.addObserver(viewModel)
// Populate view model with initial data
@ -82,23 +86,17 @@ class AddSiteActivity : DarkModeSwitchActivity() {
// Name
inputName.attachLiveData(this, viewModel.name)
viewModel.onNameError()
.toViewError(this, inputName)
// Tags
inputTags.attachLiveData(this, viewModel.tags)
// Url
inputUrl.attachLiveData(this, viewModel.url)
viewModel.onUrlError()
.toViewError(this, inputUrl)
viewModel.onUrlWarningVisibility()
.toViewVisibility(this, textUrlWarning)
// Timeout
responseTimeoutInput.attachLiveData(this, viewModel.timeout)
viewModel.onTimeoutError()
.toViewError(this, responseTimeoutInput)
// Validation mode
responseValidationMode.attachLiveData(
@ -107,8 +105,6 @@ class AddSiteActivity : DarkModeSwitchActivity() {
outTransformer = { ValidationMode.fromIndex(it) },
inTransformer = { it.toIndex() }
)
viewModel.onValidationSearchTermError()
.toViewError(this, responseValidationSearchTerm)
viewModel.onValidationModeDescription()
.toViewText(this, validationModeDescription)
@ -124,15 +120,15 @@ class AddSiteActivity : DarkModeSwitchActivity() {
// Validation script
scriptInputLayout.attach(
codeData = viewModel.validationScript,
errorData = viewModel.onValidationScriptError(),
visibility = viewModel.onValidationScriptVisibility()
visibility = viewModel.onValidationScriptVisibility(),
form = validationForm
)
// Check interval
checkIntervalLayout.attach(
valueData = viewModel.checkIntervalValue,
multiplierData = viewModel.checkIntervalUnit,
errorData = viewModel.onCheckIntervalError()
form = validationForm
)
// Retry Policy
@ -145,8 +141,6 @@ class AddSiteActivity : DarkModeSwitchActivity() {
sslCertificateInput.onTextChanged { viewModel.certificateUri.value = it }
viewModel.certificateUri.distinct()
.observe(this, Observer { sslCertificateInput.setTextAndMaintainSelection(it) })
viewModel.onCertificateError()
.toViewError(this, sslCertificateInput)
// Headers
headersLayout.attach(viewModel.headers)
@ -156,15 +150,6 @@ class AddSiteActivity : DarkModeSwitchActivity() {
toolbarTitle.setText(R.string.add_site)
toolbar.run {
inflateMenu(R.menu.menu_addsite)
setOnMenuItemClickListener {
if (it.itemId == R.id.commit) {
viewModel.commit {
setResult(RESULT_OK)
finish()
}
}
true
}
setNavigationIcon(R.drawable.ic_action_close)
setNavigationOnClickListener { finish() }
}
@ -195,6 +180,38 @@ class AddSiteActivity : DarkModeSwitchActivity() {
}
}
private fun setupValidation() {
validationForm = form {
input(inputName, name = "Name") {
isNotEmpty().description(R.string.please_enter_name)
}
input(inputUrl, name = "URL") {
isNotEmpty().description(R.string.please_enter_url)
isUrl().description(R.string.please_enter_valid_url)
}
input(responseTimeoutInput, name = "Timeout", optional = true) {
isNumber().greaterThan(0)
.description(R.string.please_enter_networkTimeout)
}
input(responseValidationSearchTerm, name = "Search term") {
conditional(responseValidationSearchTerm.isVisibleCondition()) {
isNotEmpty().description(R.string.please_enter_search_term)
}
}
input(sslCertificateInput, name = "Certificate Path") {
isUri().hasScheme("file", "content")
.that { it.host != null }
.description(R.string.please_enter_validCertUri)
}
submitWith(toolbar.menu, R.id.commit) {
viewModel.commit {
setResult(RESULT_OK)
finish()
}
}
}
}
override fun onResume() {
super.onResume()
appToolbar.elevation = if (scrollView.scrollY > appToolbar.measuredHeight / 2) {

View file

@ -39,9 +39,7 @@ import com.afollestad.nocknock.data.putSite
import com.afollestad.nocknock.engine.validation.ValidationExecutor
import com.afollestad.nocknock.ui.ScopedViewModel
import com.afollestad.nocknock.utilities.ext.MINUTE
import com.afollestad.nocknock.utilities.ext.toUri
import com.afollestad.nocknock.utilities.livedata.map
import com.afollestad.nocknock.viewcomponents.ext.isNullOrLessThan
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ -83,23 +81,10 @@ class AddSiteViewModel(
headers.value = emptyList()
}
// Private properties
private val isLoading = MutableLiveData<Boolean>()
private val nameError = MutableLiveData<Int?>()
private val urlError = MutableLiveData<Int?>()
private val timeoutError = MutableLiveData<Int?>()
private val validationSearchTermError = MutableLiveData<Int?>()
private val validationScriptError = MutableLiveData<Int?>()
private val checkIntervalValueError = MutableLiveData<Int?>()
private val certificateError = MutableLiveData<Int?>()
// Expose private properties or calculated properties
@CheckResult fun onIsLoading(): LiveData<Boolean> = isLoading
@CheckResult fun onNameError(): LiveData<Int?> = nameError
@CheckResult fun onUrlError(): LiveData<Int?> = urlError
@CheckResult fun onUrlWarningVisibility(): LiveData<Boolean> {
return url.map {
val parsed = HttpUrl.parse(it)
@ -107,8 +92,6 @@ class AddSiteViewModel(
}
}
@CheckResult fun onTimeoutError(): LiveData<Int?> = timeoutError
@CheckResult fun onValidationModeDescription(): LiveData<Int> {
return validationMode.map {
when (it!!) {
@ -119,19 +102,9 @@ class AddSiteViewModel(
}
}
@CheckResult fun onValidationSearchTermError(): LiveData<Int?> = validationSearchTermError
@CheckResult fun onValidationSearchTermVisibility() = validationMode.map { it == TERM_SEARCH }
@CheckResult fun onValidationSearchTermVisibility() =
validationMode.map { it == TERM_SEARCH }
@CheckResult fun onValidationScriptError(): LiveData<Int?> = validationScriptError
@CheckResult fun onValidationScriptVisibility() =
validationMode.map { it == JAVASCRIPT }
@CheckResult fun onCheckIntervalError(): LiveData<Int?> = checkIntervalValueError
@CheckResult fun onCertificateError(): LiveData<Int?> = certificateError
@CheckResult fun onValidationScriptVisibility() = validationMode.map { it == JAVASCRIPT }
// Actions
fun commit(done: () -> Unit) {
@ -171,89 +144,7 @@ class AddSiteViewModel(
}
private fun generateDbModel(): Site? {
var errorCount = 0
// Validation name
if (name.value.isNullOrEmpty()) {
nameError.value = R.string.please_enter_name
errorCount++
} else {
nameError.value = null
}
// Validate URL
when {
url.value.isNullOrEmpty() -> {
urlError.value = R.string.please_enter_url
errorCount++
}
HttpUrl.parse(url.value!!) == null -> {
urlError.value = R.string.please_enter_valid_url
errorCount++
}
else -> {
urlError.value = null
}
}
// Validate timeout
val timeout = timeout.value ?: 10_000
if (timeout < 0) {
timeoutError.value = R.string.please_enter_networkTimeout
errorCount++
} else {
timeoutError.value = null
}
// Validate check interval
if (checkIntervalValue.value.isNullOrLessThan(1)) {
checkIntervalValueError.value = R.string.please_enter_check_interval
errorCount++
} else {
checkIntervalValueError.value = null
}
// Validate arguments
if (validationMode.value == TERM_SEARCH &&
validationSearchTerm.value.isNullOrEmpty()
) {
errorCount++
validationSearchTermError.value = R.string.please_enter_search_term
validationScriptError.value = null
} else if (validationMode.value == JAVASCRIPT &&
validationScript.value.isNullOrEmpty()
) {
errorCount++
validationSearchTermError.value = null
validationScriptError.value = R.string.please_enter_javaScript
} else {
validationSearchTermError.value = null
validationScriptError.value = null
}
// Validate SSL certificate
val certString = certificateUri.value
if (certString != null) {
val rawCertUri = certString.toUri()
val certUri = if (rawCertUri.scheme == null) {
rawCertUri.buildUpon()
.scheme("file")
.build()
} else {
rawCertUri
}
if (certUri.scheme != "content" && certUri.scheme != "file") {
errorCount++
certificateError.value = R.string.please_enter_validCertUri
} else {
certificateError.value = null
}
}
if (errorCount > 0) {
return null
}
val cleanedTags = tags.value?.split(',')?.joinToString { it.trim() } ?: ""
val newSettings = SiteSettings(
@ -275,7 +166,8 @@ class AddSiteViewModel(
val retryPolicyMinutes = retryPolicyMinutes.value ?: 0
val newRetryPolicy: RetryPolicy? = if (retryPolicyTimes > 0 && retryPolicyMinutes > 0) {
RetryPolicy(
count = retryPolicyTimes, minutes = retryPolicyMinutes
count = retryPolicyTimes,
minutes = retryPolicyMinutes
)
} else {
null

View file

@ -29,9 +29,7 @@
<string name="please_enter_name">Please enter a name!</string>
<string name="please_enter_url">Please enter a URL.</string>
<string name="please_enter_valid_url">Please enter a valid URL.</string>
<string name="please_enter_check_interval">Please input a validation interval.</string>
<string name="please_enter_search_term">Please input a search term.</string>
<string name="please_enter_javaScript">Please input a validation script.</string>
<string name="please_enter_networkTimeout">Please enter a network timeout greater than 0.</string>
<string name="please_enter_validCertUri">Certificate should be a valid file or content URI.</string>

View file

@ -149,247 +149,9 @@ class AddSiteViewModelTest {
assertThat(viewModel.getValidationArgs()).isEqualTo("Two")
}
@Test fun commit_nameError() {
val onNameError = viewModel.onNameError()
.test()
val onUrlError = viewModel.onUrlError()
.test()
val onTimeoutError = viewModel.onTimeoutError()
.test()
val onCheckIntervalError = viewModel.onCheckIntervalError()
.test()
val onSearchTermError = viewModel.onValidationSearchTermError()
.test()
val onScriptError = viewModel.onValidationScriptError()
.test()
fillInModel().apply {
name.value = ""
}
val onDone = mock<() -> Unit>()
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertValues(R.string.please_enter_name)
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
onCheckIntervalError.assertNoValues()
onSearchTermError.assertNoValues()
onScriptError.assertNoValues()
verify(onDone, never()).invoke()
}
@Test fun commit_urlEmptyError() {
val onNameError = viewModel.onNameError()
.test()
val onUrlError = viewModel.onUrlError()
.test()
val onTimeoutError = viewModel.onTimeoutError()
.test()
val onCheckIntervalError = viewModel.onCheckIntervalError()
.test()
val onSearchTermError = viewModel.onValidationSearchTermError()
.test()
val onScriptError = viewModel.onValidationScriptError()
.test()
fillInModel().apply {
url.value = ""
}
val onDone = mock<() -> Unit>()
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertValues(R.string.please_enter_url)
onTimeoutError.assertNoValues()
onCheckIntervalError.assertNoValues()
onSearchTermError.assertNoValues()
onScriptError.assertNoValues()
verify(onDone, never()).invoke()
}
@Test fun commit_urlFormatError() {
val onNameError = viewModel.onNameError()
.test()
val onUrlError = viewModel.onUrlError()
.test()
val onTimeoutError = viewModel.onTimeoutError()
.test()
val onCheckIntervalError = viewModel.onCheckIntervalError()
.test()
val onSearchTermError = viewModel.onValidationSearchTermError()
.test()
val onScriptError = viewModel.onValidationScriptError()
.test()
fillInModel().apply {
url.value = "ftp://www.idk.com"
}
val onDone = mock<() -> Unit>()
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertValues(R.string.please_enter_valid_url)
onTimeoutError.assertNoValues()
onCheckIntervalError.assertNoValues()
onSearchTermError.assertNoValues()
onScriptError.assertNoValues()
verify(onDone, never()).invoke()
}
@Test fun commit_networkTimeout_error() {
val onNameError = viewModel.onNameError()
.test()
val onUrlError = viewModel.onUrlError()
.test()
val onTimeoutError = viewModel.onTimeoutError()
.test()
val onCheckIntervalError = viewModel.onCheckIntervalError()
.test()
val onSearchTermError = viewModel.onValidationSearchTermError()
.test()
val onScriptError = viewModel.onValidationScriptError()
.test()
fillInModel().apply {
timeout.value = 0
}
val onDone = mock<() -> Unit>()
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertValues(R.string.please_enter_networkTimeout)
onCheckIntervalError.assertNoValues()
onSearchTermError.assertNoValues()
onScriptError.assertNoValues()
verify(onDone, never()).invoke()
}
@Test fun commit_checkIntervalError() {
val onNameError = viewModel.onNameError()
.test()
val onUrlError = viewModel.onUrlError()
.test()
val onTimeoutError = viewModel.onTimeoutError()
.test()
val onCheckIntervalError = viewModel.onCheckIntervalError()
.test()
val onSearchTermError = viewModel.onValidationSearchTermError()
.test()
val onScriptError = viewModel.onValidationScriptError()
.test()
fillInModel().apply {
checkIntervalValue.value = 0
}
val onDone = mock<() -> Unit>()
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
onCheckIntervalError.assertValues(R.string.please_enter_check_interval)
onSearchTermError.assertNoValues()
onScriptError.assertNoValues()
verify(onDone, never()).invoke()
}
@Test fun commit_termSearchError() {
val onNameError = viewModel.onNameError()
.test()
val onUrlError = viewModel.onUrlError()
.test()
val onTimeoutError = viewModel.onTimeoutError()
.test()
val onCheckIntervalError = viewModel.onCheckIntervalError()
.test()
val onSearchTermError = viewModel.onValidationSearchTermError()
.test()
val onScriptError = viewModel.onValidationScriptError()
.test()
fillInModel().apply {
validationMode.value = TERM_SEARCH
validationSearchTerm.value = ""
}
val onDone = mock<() -> Unit>()
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
onCheckIntervalError.assertNoValues()
onSearchTermError.assertValues(R.string.please_enter_search_term)
onScriptError.assertNoValues()
verify(onDone, never()).invoke()
}
@Test fun commit_javaScript_error() {
val onNameError = viewModel.onNameError()
.test()
val onUrlError = viewModel.onUrlError()
.test()
val onTimeoutError = viewModel.onTimeoutError()
.test()
val onCheckIntervalError = viewModel.onCheckIntervalError()
.test()
val onSearchTermError = viewModel.onValidationSearchTermError()
.test()
val onScriptError = viewModel.onValidationScriptError()
.test()
fillInModel().apply {
validationMode.value = JAVASCRIPT
validationScript.value = ""
}
val onDone = mock<() -> Unit>()
viewModel.commit(onDone)
verify(validationManager, never())
.scheduleValidation(any(), any(), any(), any())
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
onCheckIntervalError.assertNoValues()
onSearchTermError.assertNoValues()
onScriptError.assertValues(R.string.please_enter_javaScript)
verify(onDone, never()).invoke()
}
@Test fun commit_success() = runBlocking {
val isLoading = viewModel.onIsLoading()
.test()
val onNameError = viewModel.onNameError()
.test()
val onUrlError = viewModel.onUrlError()
.test()
val onTimeoutError = viewModel.onTimeoutError()
.test()
val onSearchTermError = viewModel.onValidationSearchTermError()
.test()
val onScriptError = viewModel.onValidationScriptError()
.test()
val onCheckIntervalError = viewModel.onCheckIntervalError()
.test()
fillInModel()
val onDone = mock<() -> Unit>()
@ -416,12 +178,6 @@ class AddSiteViewModelTest {
cancelPrevious = true,
fromFinishingJob = false
)
onNameError.assertNoValues()
onUrlError.assertNoValues()
onTimeoutError.assertNoValues()
onCheckIntervalError.assertNoValues()
onSearchTermError.assertNoValues()
onScriptError.assertNoValues()
verify(onDone).invoke()
}

View file

@ -18,6 +18,8 @@ dependencies {
implementation project(':common')
implementation project(':data')
api 'com.afollestad:vvalidator:' + versions.vvalidator
implementation 'androidx.appcompat:appcompat:' + versions.androidxCore
implementation 'com.google.android.material:material:' + versions.googleMaterial
api 'androidx.lifecycle:lifecycle-extensions:' + versions.lifecycle

View file

@ -17,19 +17,15 @@ package com.afollestad.nocknock.viewcomponents.ext
import android.view.View
import android.view.View.GONE
import android.view.View.INVISIBLE
import android.view.View.VISIBLE
import android.view.ViewTreeObserver
import androidx.annotation.DimenRes
import com.afollestad.vvalidator.form.Condition
fun View.show() {
visibility = VISIBLE
}
fun View.conceal() {
visibility = INVISIBLE
}
fun View.hide() {
visibility = GONE
}
@ -51,3 +47,7 @@ fun View.onLayout(cb: () -> Unit) {
fun View.dimenFloat(@DimenRes res: Int) = resources.getDimension(res)
fun View.dimenInt(@DimenRes res: Int) = resources.getDimensionPixelSize(res)
fun View.isVisibleCondition(): Condition = {
visibility == VISIBLE
}

View file

@ -19,7 +19,6 @@ import android.content.Context
import android.util.AttributeSet
import android.widget.ArrayAdapter
import android.widget.LinearLayout
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.afollestad.nocknock.utilities.ext.DAY
import com.afollestad.nocknock.utilities.ext.HOUR
@ -28,7 +27,7 @@ import com.afollestad.nocknock.utilities.ext.WEEK
import com.afollestad.nocknock.viewcomponents.R
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner
import com.afollestad.nocknock.viewcomponents.livedata.toViewError
import com.afollestad.vvalidator.form.Form
import kotlinx.android.synthetic.main.validation_interval_layout.view.input
import kotlinx.android.synthetic.main.validation_interval_layout.view.spinner
@ -66,8 +65,14 @@ class ValidationIntervalLayout(
fun attach(
valueData: MutableLiveData<Int>,
multiplierData: MutableLiveData<Long>,
errorData: LiveData<Int?>
form: Form
) {
form.input(input, name = "Interval") {
isNotEmpty().description(R.string.please_enter_check_interval)
length().greaterThan(0)
.description(R.string.check_interval_must_be_greater_zero)
}
input.attachLiveData(lifecycleOwner(), valueData)
spinner.attachLiveData(
lifecycleOwner = lifecycleOwner(),
@ -91,10 +96,5 @@ class ValidationIntervalLayout(
}
}
)
errorData.toViewError(lifecycleOwner(), this, ::setError)
}
private fun setError(error: String?) {
input.error = error
}
}

View file

@ -20,15 +20,17 @@ import android.util.AttributeSet
import android.widget.HorizontalScrollView
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.afollestad.nocknock.viewcomponents.R
import com.afollestad.nocknock.viewcomponents.R.dimen
import com.afollestad.nocknock.viewcomponents.R.layout
import com.afollestad.nocknock.viewcomponents.ext.dimenFloat
import com.afollestad.nocknock.viewcomponents.ext.dimenInt
import com.afollestad.nocknock.viewcomponents.ext.isVisibleCondition
import com.afollestad.nocknock.viewcomponents.ext.showOrHide
import com.afollestad.nocknock.viewcomponents.livedata.attachLiveData
import com.afollestad.nocknock.viewcomponents.livedata.lifecycleOwner
import com.afollestad.nocknock.viewcomponents.livedata.toViewError
import com.afollestad.nocknock.viewcomponents.livedata.toViewVisibility
import com.afollestad.vvalidator.form.Form
import kotlinx.android.synthetic.main.javascript_input_layout.view.error_text
import kotlinx.android.synthetic.main.javascript_input_layout.view.userInput
@ -55,11 +57,20 @@ class JavaScriptInputLayout(
fun attach(
codeData: MutableLiveData<String>,
errorData: LiveData<Int?>,
visibility: LiveData<Boolean>
visibility: LiveData<Boolean>,
form: Form
) {
form.input(userInput, name = "Script") {
conditional(isVisibleCondition()) {
isNotEmpty().description(R.string.please_enter_javaScript)
}
onErrors { _, errors ->
val error = errors.firstOrNull()
setError(error.toString())
}
}
userInput.attachLiveData(lifecycleOwner(), codeData)
errorData.toViewError(lifecycleOwner(), this, ::setError)
visibility.toViewVisibility(lifecycleOwner(), this)
}

View file

@ -23,4 +23,8 @@
<string name="header_name">Header Name</string>
<string name="header_value">Header Value</string>
<string name="please_enter_check_interval">Please input a validation interval.</string>
<string name="check_interval_must_be_greater_zero">The validation interval must be greater than 0.</string>
<string name="please_enter_javaScript">Please input a validation script.</string>
</resources>