Skip to content

Commit 76779ee

Browse files
committed
Add validation for various controllers
1 parent 30c4fe3 commit 76779ee

File tree

23 files changed

+565
-36
lines changed

23 files changed

+565
-36
lines changed

identity/src/main/java/com/stripe/android/identity/ui/DOBSection.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ internal object DobTextFieldConfig : SimpleTextFieldConfig(
112112
override val visualTransformation = MaskVisualTransformation(DATE_MASK)
113113

114114
override fun determineState(input: String): TextFieldState = object : TextFieldState {
115-
override fun shouldShowError(hasFocus: Boolean) =
115+
override fun shouldShowError(hasFocus: Boolean, isValidating: Boolean) =
116116
!hasFocus && input.isNotBlank() && !input.isValidDate()
117117

118118
override fun isValid(): Boolean = input.isNotBlank()

identity/src/main/java/com/stripe/android/identity/ui/IDNumberSection.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ private object USIDConfig : SimpleTextFieldConfig(
201201
override val keyboard = KeyboardType.Number
202202
override val visualTransformation = Last4SSNTransformation
203203
override fun determineState(input: String): TextFieldState = object : TextFieldState {
204-
override fun shouldShowError(hasFocus: Boolean) = !hasFocus && input.length < 4
204+
override fun shouldShowError(hasFocus: Boolean, isValidating: Boolean) = !hasFocus && input.length < 4
205205

206206
override fun isValid(): Boolean = input.isNotBlank()
207207

@@ -221,7 +221,7 @@ private object BRIDConfig : SimpleTextFieldConfig(
221221
override val visualTransformation = BRVisualTransformation
222222
override val optional: Boolean = false
223223
override fun determineState(input: String): TextFieldState = object : TextFieldState {
224-
override fun shouldShowError(hasFocus: Boolean) = !hasFocus && input.length < 11
224+
override fun shouldShowError(hasFocus: Boolean, isValidating: Boolean) = !hasFocus && input.length < 11
225225

226226
override fun isValid(): Boolean = input.isNotBlank()
227227

@@ -239,7 +239,7 @@ private object SGIDConfig : SimpleTextFieldConfig(
239239
override val placeHolder = SINGAPORE_ID_PLACEHOLDER
240240
override val optional: Boolean = false
241241
override fun determineState(input: String): TextFieldState = object : TextFieldState {
242-
override fun shouldShowError(hasFocus: Boolean) = false
242+
override fun shouldShowError(hasFocus: Boolean, isValidating: Boolean) = false
243243

244244
override fun isValid(): Boolean = input.isNotBlank()
245245

payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CardNumberController.kt

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.stripe.android.ui.core.elements
33
import androidx.annotation.VisibleForTesting
44
import androidx.compose.runtime.Composable
55
import androidx.compose.runtime.LaunchedEffect
6+
import androidx.compose.runtime.SideEffect
67
import androidx.compose.runtime.getValue
78
import androidx.compose.runtime.mutableStateOf
89
import androidx.compose.runtime.saveable.rememberSaveable
@@ -281,13 +282,14 @@ internal class DefaultCardNumberController(
281282
}
282283
override val fieldState: StateFlow<TextFieldState> = _fieldState
283284

285+
private val _isValidating = MutableStateFlow(false)
284286
private val _hasFocus = MutableStateFlow(false)
285287

286288
override val loading: StateFlow<Boolean> = accountRangeService.isLoading
287289

288290
override val visibleError: StateFlow<Boolean> =
289-
combineAsStateFlow(_fieldState, _hasFocus) { fieldState, hasFocus ->
290-
fieldState.shouldShowError(hasFocus)
291+
combineAsStateFlow(_fieldState, _hasFocus, _isValidating) { fieldState, hasFocus, isValidating ->
292+
fieldState.shouldShowError(hasFocus, isValidating)
291293
}
292294

293295
/**
@@ -335,6 +337,10 @@ internal class DefaultCardNumberController(
335337
mostRecentUserSelectedBrand.value = CardBrand.fromCode(item.id)
336338
}
337339

340+
override fun onValidationStateChanged(isValidating: Boolean) {
341+
_isValidating.value = isValidating
342+
}
343+
338344
fun determineSelectedBrand(
339345
previous: CardBrand?,
340346
allChoices: List<CardBrand>,
@@ -372,9 +378,7 @@ internal class DefaultCardNumberController(
372378

373379
// Remember the last state indicating whether it was a disallowed card brand error
374380
var lastLoggedCardBrand by rememberSaveable { mutableStateOf<CardBrand?>(null) }
375-
var hasReportedIncompleteCardNumberRequiringMoreThan16Digits by rememberSaveable {
376-
mutableStateOf(false)
377-
}
381+
var hasReportedIncompleteCardNumberRequiringMoreThan16Digits by rememberSaveable { mutableStateOf(false) }
378382

379383
LaunchedEffect(Unit) {
380384
// Drop the set empty value & initial value

payments-ui-core/src/main/java/com/stripe/android/ui/core/elements/CvcController.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,12 @@ class CvcController constructor(
7272
}
7373
override val fieldState: StateFlow<TextFieldState> = _fieldState
7474

75+
private val _isValidating = MutableStateFlow(false)
7576
private val _hasFocus = MutableStateFlow(false)
7677

7778
override val visibleError: StateFlow<Boolean> =
78-
combineAsStateFlow(_fieldState, _hasFocus) { fieldState, hasFocus ->
79-
fieldState.shouldShowError(hasFocus)
79+
combineAsStateFlow(_fieldState, _hasFocus, _isValidating) { fieldState, hasFocus, isValidating ->
80+
fieldState.shouldShowError(hasFocus, isValidating)
8081
}
8182

8283
/**
@@ -123,4 +124,8 @@ class CvcController constructor(
123124
override fun onFocusChange(newHasFocus: Boolean) {
124125
_hasFocus.value = newHasFocus
125126
}
127+
128+
override fun onValidationStateChanged(isValidating: Boolean) {
129+
_isValidating.value = isValidating
130+
}
126131
}

payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CardNumberControllerTest.kt

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,6 +701,48 @@ internal class CardNumberControllerTest {
701701
assertThat(cardNumberController.layoutDirection).isEqualTo(LayoutDirection.Ltr)
702702
}
703703

704+
@Test
705+
fun `Verify 'onValidationStateChanged' with 'true' results in an error when incomplete card number`() = runTest {
706+
val cardNumberController = createController()
707+
708+
cardNumberController.error.test {
709+
assertThat(awaitItem()).isNull()
710+
711+
cardNumberController.onFocusChange(true)
712+
cardNumberController.onValueChange("4242")
713+
714+
cardNumberController.onValidationStateChanged(true)
715+
assertThat(awaitItem()?.errorMessage).isEqualTo(StripeR.string.stripe_invalid_card_number)
716+
}
717+
}
718+
719+
@Test
720+
fun `Verify 'onValidationStateChanged' with 'true' & complete card number shows no error`() = runTest {
721+
val cardNumberController = createController()
722+
723+
cardNumberController.error.test {
724+
assertThat(awaitItem()).isNull()
725+
726+
cardNumberController.onValueChange("4242424242424242")
727+
expectNoEvents()
728+
729+
cardNumberController.onValidationStateChanged(true)
730+
expectNoEvents()
731+
}
732+
}
733+
734+
@Test
735+
fun `Verify 'onValidationStateChanged' with 'true' results in an error when empty card number`() = runTest {
736+
val cardNumberController = createController()
737+
738+
cardNumberController.error.test {
739+
assertThat(awaitItem()).isNull()
740+
741+
cardNumberController.onValidationStateChanged(true)
742+
assertThat(awaitItem()?.errorMessage).isEqualTo(StripeUiCoreR.string.stripe_blank_and_required)
743+
}
744+
}
745+
704746
private fun createController(
705747
initialValue: String? = null,
706748
cardBrandChoiceConfig: CardBrandChoiceConfig = CardBrandChoiceConfig.Ineligible,

payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/CvcControllerTest.kt

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,52 @@ internal class CvcControllerTest {
7575
assertThat(cvcController.layoutDirection).isEqualTo(LayoutDirection.Ltr)
7676
}
7777

78+
@Test
79+
fun `Verify 'onValidationStateChanged' with 'true' results in an error when incomplete CVC`() = runTest {
80+
val cvcController = createController()
81+
82+
cvcController.error.test {
83+
assertThat(awaitItem()).isNull()
84+
85+
cvcController.onFocusChange(true)
86+
cvcController.onValueChange("12")
87+
88+
cvcController.onValidationStateChanged(true)
89+
assertThat(awaitItem()?.errorMessage).isEqualTo(StripeR.string.stripe_invalid_cvc)
90+
}
91+
}
92+
93+
@Test
94+
fun `Verify 'onValidationStateChanged' with 'true' & complete CVC shows no error`() = runTest {
95+
val cvcController = createController()
96+
97+
cvcController.error.test {
98+
assertThat(awaitItem()).isNull()
99+
100+
cvcController.onValueChange("123")
101+
expectNoEvents()
102+
103+
cvcController.onValidationStateChanged(true)
104+
expectNoEvents()
105+
}
106+
}
107+
108+
@Test
109+
fun `Verify 'onValidationStateChanged' with 'true' results in an error when empty CVC`() = runTest {
110+
val cvcController = createController()
111+
112+
cvcController.error.test {
113+
assertThat(awaitItem()).isNull()
114+
115+
cvcController.onValidationStateChanged(true)
116+
assertThat(awaitItem()?.errorMessage).isEqualTo(StripeUiCoreR.string.stripe_blank_and_required)
117+
118+
cvcController.onFocusChange(true)
119+
cvcController.onValidationStateChanged(false)
120+
assertThat(awaitItem()).isNull()
121+
}
122+
}
123+
78124
private fun createController(): CvcController {
79125
return CvcController(
80126
CvcConfig(),

payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/DropdownFieldControllerTest.kt

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package com.stripe.android.ui.core.elements
22

3+
import app.cash.turbine.test
34
import com.google.common.truth.Truth.assertThat
5+
import com.stripe.android.uicore.R
46
import com.stripe.android.uicore.elements.CountryConfig
57
import com.stripe.android.uicore.elements.DropdownConfig
68
import com.stripe.android.uicore.elements.DropdownFieldController
79
import kotlinx.coroutines.flow.first
810
import kotlinx.coroutines.runBlocking
11+
import kotlinx.coroutines.test.runTest
912
import org.junit.Test
1013
import java.util.Locale
1114

@@ -69,4 +72,54 @@ class DropdownFieldControllerTest {
6972
assertThat(controller.selectedIndex.value).isNull()
7073
assertThat(controller.isComplete.value).isFalse()
7174
}
75+
76+
@Test
77+
fun `Verify no error when no selection is made and not validating`() = runTest {
78+
val countryConfig = CountryConfig(
79+
locale = Locale.US,
80+
mode = DropdownConfig.Mode.Full(selectsFirstOptionAsDefault = false),
81+
)
82+
val controller = DropdownFieldController(countryConfig)
83+
84+
controller.error.test {
85+
assertThat(awaitItem()).isNull()
86+
}
87+
}
88+
89+
@Test
90+
fun `Verify error when no selection is made and validating`() = runTest {
91+
val countryConfig = CountryConfig(
92+
locale = Locale.US,
93+
mode = DropdownConfig.Mode.Full(selectsFirstOptionAsDefault = false),
94+
)
95+
val controller = DropdownFieldController(countryConfig)
96+
97+
controller.error.test {
98+
assertThat(awaitItem()).isNull()
99+
100+
controller.onValidationStateChanged(true)
101+
102+
assertThat(awaitItem()?.errorMessage).isEqualTo(R.string.stripe_blank_and_required)
103+
}
104+
}
105+
106+
@Test
107+
fun `Verify no error when selection is made`() = runTest {
108+
val countryConfig = CountryConfig(
109+
locale = Locale.US,
110+
mode = DropdownConfig.Mode.Full(selectsFirstOptionAsDefault = false),
111+
)
112+
val controller = DropdownFieldController(countryConfig)
113+
114+
controller.error.test {
115+
assertThat(awaitItem()).isNull()
116+
117+
controller.onValueChange(0)
118+
expectNoEvents()
119+
120+
controller.onValidationStateChanged(true)
121+
122+
expectNoEvents()
123+
}
124+
}
72125
}

payments-ui-core/src/test/java/com/stripe/android/ui/core/elements/PhoneNumberControllerTest.kt

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.compose.ui.text.AnnotatedString
44
import app.cash.turbine.test
55
import app.cash.turbine.turbineScope
66
import com.google.common.truth.Truth.assertThat
7+
import com.stripe.android.uicore.R
78
import com.stripe.android.uicore.elements.PhoneNumberController
89
import kotlinx.coroutines.test.runTest
910
import org.junit.Test
@@ -145,7 +146,6 @@ internal class PhoneNumberControllerTest {
145146
assertThat(awaitItem()).isNotNull()
146147

147148
phoneNumberController.onValueChange("1234567891")
148-
skipItems(1)
149149

150150
assertThat(awaitItem()).isNull()
151151
}
@@ -207,4 +207,99 @@ internal class PhoneNumberControllerTest {
207207
assertThat(awaitItem()).isEmpty()
208208
}
209209
}
210+
211+
@Test
212+
fun `test error is shown when validating and incomplete`() = runTest {
213+
val phoneNumberController = PhoneNumberController.createPhoneNumberController(
214+
initiallySelectedCountryCode = "US",
215+
)
216+
217+
phoneNumberController.error.test {
218+
assertThat(awaitItem()).isNull()
219+
220+
phoneNumberController.onValueChange("123")
221+
assertThat(awaitItem()?.errorMessage).isEqualTo(R.string.stripe_incomplete_phone_number)
222+
223+
phoneNumberController.onFocusChange(true)
224+
assertThat(awaitItem()).isNull()
225+
226+
phoneNumberController.onValidationStateChanged(true)
227+
assertThat(awaitItem()?.errorMessage).isEqualTo(R.string.stripe_incomplete_phone_number)
228+
}
229+
}
230+
231+
@Test
232+
fun `test error is not shown when not validating and has focus`() = runTest {
233+
val phoneNumberController = PhoneNumberController.createPhoneNumberController(
234+
initiallySelectedCountryCode = "US",
235+
)
236+
237+
phoneNumberController.error.test {
238+
assertThat(awaitItem()).isNull()
239+
240+
phoneNumberController.onFocusChange(true)
241+
phoneNumberController.onValueChange("123")
242+
243+
expectNoEvents()
244+
}
245+
}
246+
247+
@Test
248+
fun `test error is shown when not validating, no focus, and incomplete`() = runTest {
249+
val phoneNumberController = PhoneNumberController.createPhoneNumberController(
250+
initiallySelectedCountryCode = "US",
251+
)
252+
253+
phoneNumberController.error.test {
254+
assertThat(awaitItem()).isNull()
255+
256+
phoneNumberController.onFocusChange(true)
257+
phoneNumberController.onValueChange("123")
258+
259+
phoneNumberController.onFocusChange(false)
260+
assertThat(awaitItem()).isNotNull()
261+
}
262+
}
263+
264+
@Test
265+
fun `test error is not shown when field is complete`() = runTest {
266+
val phoneNumberController = PhoneNumberController.createPhoneNumberController(
267+
initiallySelectedCountryCode = "US",
268+
)
269+
270+
phoneNumberController.error.test {
271+
assertThat(awaitItem()).isNull()
272+
273+
// Set complete phone number
274+
phoneNumberController.onValueChange("1234567890")
275+
expectNoEvents()
276+
277+
phoneNumberController.onValidationStateChanged(true)
278+
expectNoEvents()
279+
280+
phoneNumberController.onFocusChange(false)
281+
expectNoEvents()
282+
}
283+
}
284+
285+
@Test
286+
fun `test error behavior with acceptAnyInput enabled`() = runTest {
287+
val phoneNumberController = PhoneNumberController.createPhoneNumberController(
288+
initiallySelectedCountryCode = "US",
289+
acceptAnyInput = true,
290+
)
291+
292+
phoneNumberController.error.test {
293+
assertThat(awaitItem()).isNull()
294+
295+
phoneNumberController.onValueChange("1")
296+
expectNoEvents()
297+
298+
phoneNumberController.onFocusChange(false)
299+
expectNoEvents()
300+
301+
phoneNumberController.onValidationStateChanged(true)
302+
expectNoEvents()
303+
}
304+
}
210305
}

0 commit comments

Comments
 (0)