diff --git a/README.md b/README.md index ffa7b32..331158c 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ 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()) { @@ -87,6 +87,16 @@ if (formState.validate()) { 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 +) +``` 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`) to your diff --git a/example/src/main/java/com/dsc/formbuilder/screens/survey/SurveyViewmodel.kt b/example/src/main/java/com/dsc/formbuilder/screens/survey/SurveyViewmodel.kt index b995362..4aca60c 100644 --- a/example/src/main/java/com/dsc/formbuilder/screens/survey/SurveyViewmodel.kt +++ b/example/src/main/java/com/dsc/formbuilder/screens/survey/SurveyViewmodel.kt @@ -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() { @@ -47,7 +49,7 @@ class SurveyViewmodel : ViewModel() { Validators.Required( message = "Select at least one platform" ) - ) + ), ), SelectState( name = "language", @@ -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> = 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") + } } diff --git a/example/src/main/java/com/dsc/formbuilder/screens/survey/components/SurveyModel.kt b/example/src/main/java/com/dsc/formbuilder/screens/survey/components/SurveyModel.kt new file mode 100644 index 0000000..37e02a4 --- /dev/null +++ b/example/src/main/java/com/dsc/formbuilder/screens/survey/components/SurveyModel.kt @@ -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, + val platform: List, + val language: List, + + // Third page + val os: String, + val gender: String, + val experience: String, +) diff --git a/form-builder/src/main/java/com/dsc/form_builder/BaseState.kt b/form-builder/src/main/java/com/dsc/form_builder/BaseState.kt index 1b1fdff..0d4d879 100644 --- a/form-builder/src/main/java/com/dsc/form_builder/BaseState.kt +++ b/form-builder/src/main/java/com/dsc/form_builder/BaseState.kt @@ -68,4 +68,12 @@ abstract class BaseState( 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() + } } \ No newline at end of file diff --git a/form-builder/src/main/java/com/dsc/form_builder/ChoiceState.kt b/form-builder/src/main/java/com/dsc/form_builder/ChoiceState.kt index 8bad375..40bd966 100644 --- a/form-builder/src/main/java/com/dsc/form_builder/ChoiceState.kt +++ b/form-builder/src/main/java/com/dsc/form_builder/ChoiceState.kt @@ -20,7 +20,7 @@ package com.dsc.form_builder class ChoiceState( name: String, initial: String = "", - validators: List, + validators: List = listOf(), transform: Transform? = null, ) : TextFieldState(initial = initial, name = name, validators = validators, transform = transform) { diff --git a/form-builder/src/main/java/com/dsc/form_builder/FormState.kt b/form-builder/src/main/java/com/dsc/form_builder/FormState.kt index 9e56435..499e0b0 100644 --- a/form-builder/src/main/java/com/dsc/form_builder/FormState.kt +++ b/form-builder/src/main/java/com/dsc/form_builder/FormState.kt @@ -30,7 +30,30 @@ open class FormState>(val fields: List) { fun getData(dataClass: KClass): T { val map = fields.associate { it.name to it.getData() } val constructor = dataClass.constructors.last() - val args: Map = constructor.parameters.associateWith { map[it.name] } + val args: MutableMap = 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() + } + } } \ No newline at end of file diff --git a/form-builder/src/main/java/com/dsc/form_builder/SelectState.kt b/form-builder/src/main/java/com/dsc/form_builder/SelectState.kt index 748fe1f..8dffc5c 100644 --- a/form-builder/src/main/java/com/dsc/form_builder/SelectState.kt +++ b/form-builder/src/main/java/com/dsc/form_builder/SelectState.kt @@ -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 @@ -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 @@ -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 diff --git a/form-builder/src/main/java/com/dsc/form_builder/TextFieldState.kt b/form-builder/src/main/java/com/dsc/form_builder/TextFieldState.kt index f7965d6..c134886 100644 --- a/form-builder/src/main/java/com/dsc/form_builder/TextFieldState.kt +++ b/form-builder/src/main/java/com/dsc/form_builder/TextFieldState.kt @@ -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 } diff --git a/form-builder/src/main/java/com/dsc/form_builder/Validators.kt b/form-builder/src/main/java/com/dsc/form_builder/Validators.kt index 28e72d5..a01a112 100644 --- a/form-builder/src/main/java/com/dsc/form_builder/Validators.kt +++ b/form-builder/src/main/java/com/dsc/form_builder/Validators.kt @@ -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. */ diff --git a/form-builder/src/test/java/com/dsc/form_builder/FormStateTest.kt b/form-builder/src/test/java/com/dsc/form_builder/FormStateTest.kt new file mode 100644 index 0000000..e545594 --- /dev/null +++ b/form-builder/src/test/java/com/dsc/form_builder/FormStateTest.kt @@ -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("email") + private val hobbyState = formState.getState("hobbies") + private val genderState = formState.getState("gender") + private val ageState = formState.getState("age") + + @Test + fun `state should be reset to initial values`() { + // Given a form state with values changed + emailState.change("buider@gmail.com") + 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() && !hobbyState.hasError) + assert(genderState.value == "" && !genderState.hasError) + assert(ageState.value == "34" && !ageState.hasError) + } + } +} \ No newline at end of file diff --git a/form-builder/src/test/java/com/dsc/form_builder/SelectStateProviders.kt b/form-builder/src/test/java/com/dsc/form_builder/SelectStateProviders.kt new file mode 100644 index 0000000..0905451 --- /dev/null +++ b/form-builder/src/test/java/com/dsc/form_builder/SelectStateProviders.kt @@ -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 = 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 = 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), + ) +} \ No newline at end of file diff --git a/form-builder/src/test/java/com/dsc/form_builder/SelectStateTest.kt b/form-builder/src/test/java/com/dsc/form_builder/SelectStateTest.kt new file mode 100644 index 0000000..fef7037 --- /dev/null +++ b/form-builder/src/test/java/com/dsc/form_builder/SelectStateTest.kt @@ -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, 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, max: Int, expected: Boolean){ + classToTest.value = value // set the field state + + val actual = classToTest.validateMax(max, "expected validation: $expected") + assert(actual == expected) + } + } +} \ No newline at end of file diff --git a/form-builder/src/test/java/com/dsc/form_builder/TextFieldProviders.kt b/form-builder/src/test/java/com/dsc/form_builder/TextFieldProviders.kt index d4bcea1..ef8b932 100644 --- a/form-builder/src/test/java/com/dsc/form_builder/TextFieldProviders.kt +++ b/form-builder/src/test/java/com/dsc/form_builder/TextFieldProviders.kt @@ -39,7 +39,9 @@ object CardNumberArgumentsProvider : ArgumentsProvider { override fun provideArguments(context: ExtensionContext?): Stream = 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 ) }