Skip to content

Commit

Permalink
Merge pull request #64 from jkuatdsc/develop
Browse files Browse the repository at this point in the history
Develop
  • Loading branch information
joykangangi authored May 15, 2023
2 parents d9ead88 + dcde3c4 commit 3f265f8
Show file tree
Hide file tree
Showing 13 changed files with 246 additions and 30 deletions.
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,24 @@ various properties of the state like `text`, `hasError`, `errorMessage` etc.

> Don't forget to update your state using the setter functions in each field state.
To validate your form:
To validate your form and get form data:

```kotlin
if (formState.validate()) {
val data = formState.getData(Credentials::class)
Log.d("Data", "submit: data from the form $data")
}
```
Here is what the `Credentials` data class looks like. Take note of how the property names correspond to the field values
passed when instantiating the form state.

```Kotlin
data class Credentials(
val email: String,
val gender: String,
val hobbies: List<String>
)
```

The `validate` function returns `true` if all the fields are valid. You can then access data from the form using
the `getData` function. Pass in your data class and using reflection, we convert the map (`Map<String, Any>`) to your
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.dsc.formbuilder.screens.survey

import android.util.Log
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import com.dsc.form_builder.*
import com.dsc.formbuilder.screens.survey.components.SurveyModel

class SurveyViewmodel : ViewModel() {

Expand Down Expand Up @@ -47,7 +49,7 @@ class SurveyViewmodel : ViewModel() {
Validators.Required(
message = "Select at least one platform"
)
)
),
),
SelectState(
name = "language",
Expand Down Expand Up @@ -101,14 +103,25 @@ class SurveyViewmodel : ViewModel() {
if (!formState.validate()){
val position = formState.fields.indexOfFirst { it.hasError }
_screen.value = pages.indexOfFirst { it.contains(position) }
} else _finish.value = true
} else {
logData()
_finish.value = true
}
}

fun validateScreen(screen: Int) {
val fields: List<BaseState<*>> = formState.fields.chunked(3)[screen]
if (fields.map { it.validate() }.all { it }){ // map is used so we can execute validate() on all fields in that screen
if (screen == 2) _finish.value = true
if (screen == 2){
logData()
_finish.value = true
}
_screen.value += 1
}
}

private fun logData() {
val data = formState.getData(SurveyModel::class)
Log.d("SurveyLog", "form data is $data")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.dsc.formbuilder.screens.survey.components

data class SurveyModel(
// First page
val email: String,
val number: String,
val username: String,

// Second page
val ide: List<String>,
val platform: List<String>,
val language: List<String>,

// Third page
val os: String,
val gender: String,
val experience: String,
)
8 changes: 8 additions & 0 deletions form-builder/src/main/java/com/dsc/form_builder/BaseState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,12 @@ abstract class BaseState<T>(
open fun getData(): Any? {
return if (transform == null) value else transform.transform(value)
}

/**
* This function resets all form field values to their initial states.
*/
fun reset() {
this.value = initial
this.hideError()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ package com.dsc.form_builder
class ChoiceState(
name: String,
initial: String = "",
validators: List<Validators>,
validators: List<Validators> = listOf(),
transform: Transform<String>? = null,
) : TextFieldState(initial = initial, name = name, validators = validators, transform = transform) {

Expand Down
25 changes: 24 additions & 1 deletion form-builder/src/main/java/com/dsc/form_builder/FormState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,30 @@ open class FormState<T : BaseState<*>>(val fields: List<T>) {
fun <T : Any> getData(dataClass: KClass<T>): T {
val map = fields.associate { it.name to it.getData() }
val constructor = dataClass.constructors.last()
val args: Map<KParameter, Any?> = constructor.parameters.associateWith { map[it.name] }
val args: MutableMap<KParameter, Any?> = mutableMapOf()
constructor.parameters.associateWith {
val value = map[it.name]
if (!it.isOptional) {
checkNotNull(value) {
"""
A non-null value (${it.name}) in your class doesn't have a matching field in the form data.
This will throw a NullPointerException when creating $dataClass. To solve this, you can:
1. Make the value nullable in your data class
2. Provide a default value for the parameter
""".trimIndent()
}
args[it] = value
}
}
return constructor.callBy(args)
}

/**
* This function is used to reset data in all the form fields to their initial values.
*/
fun reset() {
fields.map {
it.reset()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class SelectState(
* @param message the error message to be displayed if the state's value does not meet the validation criteria.
* @return true if the state's value is more than the [limit] passed in to the [Validators.Min] class.
*/
private fun validateMin(limit: Int, message: String): Boolean {
internal fun validateMin(limit: Int, message: String): Boolean {
val valid = value.size >= limit
if (!valid) showError(message)
return valid
Expand All @@ -88,7 +88,7 @@ class SelectState(
* @param message the error message to be displayed if the state's value does not meet the validation criteria.
* @return true if the state's value is less than the [limit] passed in to the [Validators.Max] class.
*/
private fun validateMax(limit: Int, message: String): Boolean {
internal fun validateMax(limit: Int, message: String): Boolean {
val valid = value.size <= limit
if (!valid) showError(message)
return valid
Expand All @@ -99,7 +99,7 @@ class SelectState(
* @param message the error message to be displayed if the state's value does not meet the validation criteria.
* @return true if the state's value is not empty.
*/
private fun validateRequired(message: String): Boolean {
internal fun validateRequired(message: String): Boolean {
val valid = value.isNotEmpty()
if (!valid) showError(message)
return valid
Expand Down
34 changes: 14 additions & 20 deletions form-builder/src/main/java/com/dsc/form_builder/TextFieldState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -120,28 +120,22 @@ open class TextFieldState(
}

/**
*This function validates a Card Number in [value]
*It will return true if the string value is a valid card number.
*@param message the error message passed to [showError] to display if the value is not a valid card number. By default we use the [CARD_NUMBER_MESSAGE] constant.
* This function validates a Card Number in [value]
* It will return true if the string value is a valid card number. This function makes use of the [Luhn Algorithm](https://www.creditcardvalidator.org/articles/luhn-algorithm) to verify the validity of the credit cards.
* @param message the error message passed to [showError] to display if the value is not a valid card number. By default we use the [CARD_NUMBER_MESSAGE] constant.
*/
internal fun validateCardNumber(message: String): Boolean {
val cardNumberRegex = "(^3[47][0-9]{13}\$)|" +
"(^(6541|6556)[0-9]{12}\$)|" +
"(^389[0-9]{11}\$)|" +
"(^3(?:0[0-5]|[68][0-9])[0-9]{11}\$)|" +
"(^65[4-9][0-9]{13}|64[4-9][0-9]{13}|6011[0-9]{12}|(622(?:12[6-9]|1[3-9][0-9]|[2-8][0-9][0-9]|9[01][0-9]|92[0-5])[0-9]{10})\$\n)|" +
"(^63[7-9][0-9]{13}\$)|" +
"(^(?:2131|1800|35\\d{3})\\d{11}\$)|" +
"(^9[0-9]{15}\$)|" +
"(^(6304|6706|6709|6771)[0-9]{12,15}\$)|" +
"(^(5018|5020|5038|6304|6759|6761|6763)[0-9]{8,15}\$)|" +
"(^(5[1-5][0-9]{14}|2(22[1-9][0-9]{12}|2[3-9][0-9]{13}|[3-6][0-9]{14}|7[0-1][0-9]{13}|720[0-9]{12}))\$\n)|" +
"(^(6334|6767)[0-9]{12}|(6334|6767)[0-9]{14}|(6334|6767)[0-9]{15}\$)|" +
"(^(4903|4905|4911|4936|6333|6759)[0-9]{12}|(4903|4905|4911|4936|6333|6759)[0-9]{14}|(4903|4905|4911|4936|6333|6759)[0-9]{15}|564182[0-9]{10}|564182[0-9]{12}|564182[0-9]{13}|633110[0-9]{10}|633110[0-9]{12}|633110[0-9]{13}\$\n)|" +
"(^(62[0-9]{14,17})\$)|" +
"(^4[0-9]{12}(?:[0-9]{3})?\$)|" +
"(^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14})\$)"
val valid = cardNumberRegex.toRegex().matches(value)
var checksum = 0

for (i in value.length - 1 downTo 0 step 2) {
checksum += value[i] - '0'
}
for (i in value.length - 2 downTo 0 step 2) {
val n: Int = (value[i] - '0') * 2
checksum += if (n > 9) n - 9 else n
}

val valid = checksum%10 == 0
if (!valid) showError(message)
return valid
}
Expand Down
12 changes: 12 additions & 0 deletions form-builder/src/main/java/com/dsc/form_builder/Validators.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ sealed interface Validators {

/**
* This is validator gives you the option to provide your own implementation of the validator. You can pass in a custom function to validate the field value.
*
* Example: check if a string contains the word hello
* ```kt
* Validators.Custom(
* message = "value must have hello"
* function = { it.contains("hello") }
* )
* ```
*
*[Validators].[Custom] ([message] = "A custom message" , [function] = { customFunc(customParam) } )
*
*
* @param function this is the lambda function that is called during validation. It provides the field value as a parameter and expects a [Boolean] return value.
* @param message the error message to display if the validation fails.
*/
Expand Down
42 changes: 42 additions & 0 deletions form-builder/src/test/java/com/dsc/form_builder/FormStateTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.dsc.form_builder

import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test


internal class FormStateTest {
@Nested
inner class DescribingFormState {
private val formState = FormState(
listOf(
TextFieldState(name = "email"),
SelectState(name = "hobbies"),
ChoiceState(name = "gender"),
TextFieldState(name = "age", initial = "34")
)
)

private val emailState = formState.getState<TextFieldState>("email")
private val hobbyState = formState.getState<SelectState>("hobbies")
private val genderState = formState.getState<ChoiceState>("gender")
private val ageState = formState.getState<TextFieldState>("age")

@Test
fun `state should be reset to initial values`() {
// Given a form state with values changed
emailState.change("[email protected]")
hobbyState.select("Running")
genderState.change("male")
ageState.change("56")

// When the form.reset is requested
formState.reset()

// Then all values are reset to the original state
assert(emailState.value == "" && !emailState.hasError)
assert(hobbyState.value == mutableListOf<String>() && !hobbyState.hasError)
assert(genderState.value == "" && !genderState.hasError)
assert(ageState.value == "34" && !ageState.hasError)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.dsc.form_builder

import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.ArgumentsProvider
import java.util.stream.Stream

object MinArgumentsProvider: ArgumentsProvider {
override fun provideArguments(context: ExtensionContext?): Stream<out Arguments> = Stream.of(
Arguments.of(mutableListOf("item 1"), 2, false),
Arguments.of(mutableListOf("item 1", "item 2"), 2, true),
Arguments.of(mutableListOf("item 1", "item 2", "item 3"), 2, true),
)
}

object MaxArgumentsProvider: ArgumentsProvider {
override fun provideArguments(context: ExtensionContext?): Stream<out Arguments> = Stream.of(
Arguments.of(mutableListOf("item 1"), 2, true),
Arguments.of(mutableListOf("item 1", "item 2"), 2, true),
Arguments.of(mutableListOf("item 1", "item 2", "item 3"), 2, false),
)
}
72 changes: 72 additions & 0 deletions form-builder/src/test/java/com/dsc/form_builder/SelectStateTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.dsc.form_builder

import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ArgumentsSource

internal class SelectStateTest {

@Nested
inner class DescribingStateChanges {
private val classToTest: SelectState = SelectState(name = "test")

@Test
fun `errors should be hidden`() {
// Simulate an existing validation error
classToTest.hasError = true
classToTest.errorMessage = "error message"

val newValue = "new item" // Given a TextFieldState and a new value
classToTest.select(newValue) // When change is called

assert(!classToTest.hasError)
assert(classToTest.errorMessage.isEmpty())
}

@Test
fun `state should be updated`() {
val item = "new item" // Given a TextFieldState and a new value
classToTest.select(item) // When change is called

assert(classToTest.value.contains(item)) // Then state should have the item

classToTest.unselect(item)
assert(!classToTest.value.contains(item)) // Then state should NOT have the item
}
}

@Nested
inner class DescribingValidation {
private val classToTest: SelectState = SelectState(name = "test")

@Test
fun `Validators_Required works correctly`(){
// When state is empty
val firstValidation = classToTest.validateRequired("should fail")
assert(!firstValidation)

classToTest.select("item")
val secondValidation = classToTest.validateRequired("should pass")
assert(secondValidation)
}

@ParameterizedTest
@ArgumentsSource(MinArgumentsProvider::class)
fun `Validators_Min works correctly`(value: MutableList<String>, min: Int, expected: Boolean){
classToTest.value = value // set the field state

val actual = classToTest.validateMin(min, "expected validation: $expected")
assert(actual == expected)
}

@ParameterizedTest
@ArgumentsSource(MaxArgumentsProvider::class)
fun `Validators_Max works correctly`(value: MutableList<String>, max: Int, expected: Boolean){
classToTest.value = value // set the field state

val actual = classToTest.validateMax(max, "expected validation: $expected")
assert(actual == expected)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ object CardNumberArgumentsProvider : ArgumentsProvider {
override fun provideArguments(context: ExtensionContext?): Stream<out Arguments> = Stream.of(
Arguments.of("1111111111111111", false),
Arguments.of("1111", false),
Arguments.of("4548111111111111", true)
Arguments.of("4012888888881881", true), // Visa card
Arguments.of("5105105105105100", true), // Mastercard
Arguments.of("374245455400126", true) // AMEX
)
}

Expand Down

0 comments on commit 3f265f8

Please sign in to comment.