Skip to content

Commit

Permalink
FormItem validation for analytics (#1628)
Browse files Browse the repository at this point in the history
Adds a new protocol to return validation status that only required
validators can conform to.
Keeps the current logic and additionally checks for the new status via
`triggerValidationErrorIfNeeded()`

Makes all existing public validators spi as they were not intended to be
public.
  • Loading branch information
erenbesel authored Apr 17, 2024
2 parents 052839c + 4f2460b commit bb25398
Show file tree
Hide file tree
Showing 26 changed files with 354 additions and 76 deletions.
12 changes: 12 additions & 0 deletions Adyen.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -269,12 +269,15 @@
81FC2C912BB19872007F1316 /* CancellableMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81FC2C902BB19872007F1316 /* CancellableMock.swift */; };
81FC2C982BB1BB68007F1316 /* URLProtocolMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81FC2C962BB1B9F3007F1316 /* URLProtocolMock.swift */; };
81FC2C992BB1BB69007F1316 /* URLProtocolMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81FC2C962BB1B9F3007F1316 /* URLProtocolMock.swift */; };
A001676A2BC9567B0099A6AE /* AnalyticsValidationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A00167692BC9567B0099A6AE /* AnalyticsValidationError.swift */; };
A009837D279859DC007E68C4 /* ACHDirectDebitComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A009837C279859DC007E68C4 /* ACHDirectDebitComponentTests.swift */; };
A0113D9B2763887800AD395C /* ActionNavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0113D9A2763887800AD395C /* ActionNavigationBar.swift */; };
A013F98126AFF72D00602633 /* BrazilSocialSecurityNumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A07B2CE526A5B5440008185C /* BrazilSocialSecurityNumberFormatter.swift */; };
A018094826FDD345003A8DE3 /* FormCardNumberContainerItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A018094726FDD345003A8DE3 /* FormCardNumberContainerItem.swift */; };
A01DCF0B26BD67BB00BC35B3 /* FormCardExpiryDateItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A01DCF0A26BD67BB00BC35B3 /* FormCardExpiryDateItem.swift */; };
A01DFBBF2BA887BF00205881 /* ThreadSafeAnalyticsEventDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = A01DFBBE2BA887BF00205881 /* ThreadSafeAnalyticsEventDataSource.swift */; };
A01DFBC52BB6F9FA00205881 /* ValidationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A01DFBC42BB6F9FA00205881 /* ValidationError.swift */; };
A01DFBC72BB6FBF800205881 /* CardValidationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A01DFBC62BB6FBF800205881 /* CardValidationError.swift */; };
A020EC4829E6EC4B0050B2FE /* AdyenCashAppPay.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A04F8C1E29E5950100F3F62B /* AdyenCashAppPay.framework */; };
A020EC4929E6EC4B0050B2FE /* AdyenCashAppPay.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = A04F8C1E29E5950100F3F62B /* AdyenCashAppPay.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
A020EC4F29E6ECBD0050B2FE /* PayKit in Frameworks */ = {isa = PBXBuildFile; productRef = A020EC4E29E6ECBD0050B2FE /* PayKit */; };
Expand Down Expand Up @@ -1609,13 +1612,16 @@
81FC2C902BB19872007F1316 /* CancellableMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancellableMock.swift; sourceTree = "<group>"; };
81FC2C922BB198AB007F1316 /* ImageLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageLoaderTests.swift; sourceTree = "<group>"; };
81FC2C962BB1B9F3007F1316 /* URLProtocolMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLProtocolMock.swift; sourceTree = "<group>"; };
A00167692BC9567B0099A6AE /* AnalyticsValidationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsValidationError.swift; sourceTree = "<group>"; };
A009837C279859DC007E68C4 /* ACHDirectDebitComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACHDirectDebitComponentTests.swift; sourceTree = "<group>"; };
A009837E27986B50007E68C4 /* BankDetailsEncryptorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BankDetailsEncryptorTests.swift; sourceTree = "<group>"; };
A0113D9A2763887800AD395C /* ActionNavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionNavigationBar.swift; sourceTree = "<group>"; };
A018094726FDD345003A8DE3 /* FormCardNumberContainerItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormCardNumberContainerItem.swift; sourceTree = "<group>"; };
A01D775529B250C10075BD70 /* CashAppPayDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CashAppPayDetails.swift; sourceTree = "<group>"; };
A01DCF0A26BD67BB00BC35B3 /* FormCardExpiryDateItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormCardExpiryDateItem.swift; sourceTree = "<group>"; };
A01DFBBE2BA887BF00205881 /* ThreadSafeAnalyticsEventDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeAnalyticsEventDataSource.swift; sourceTree = "<group>"; };
A01DFBC42BB6F9FA00205881 /* ValidationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidationError.swift; sourceTree = "<group>"; };
A01DFBC62BB6FBF800205881 /* CardValidationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardValidationError.swift; sourceTree = "<group>"; };
A020EC5929ED33F90050B2FE /* CashAppPayComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CashAppPayComponentTests.swift; sourceTree = "<group>"; };
A023A9B328103C2D004FDCA4 /* session_response.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = session_response.json; sourceTree = "<group>"; };
A026C85027C4FE4700E6C34A /* BasicComponentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicComponentConfiguration.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3310,6 +3316,7 @@
A0DB486D2AFD0BFC00348C83 /* AnalyticsEventError.swift */,
A09FB2EC2B86051000270D51 /* AnalyticsEventDataSource.swift */,
A01DFBBE2BA887BF00205881 /* ThreadSafeAnalyticsEventDataSource.swift */,
A00167692BC9567B0099A6AE /* AnalyticsValidationError.swift */,
);
path = Models;
sourceTree = "<group>";
Expand Down Expand Up @@ -4401,6 +4408,7 @@
E9E3DACC221EEAB200697074 /* CardSecurityCodeValidator.swift */,
E9E3DAD0221EEE2100697074 /* CardExpiryDateValidator.swift */,
F9D5751123756BD0009C18B5 /* CardPublicKeyValidator.swift */,
A01DFBC62BB6FBF800205881 /* CardValidationError.swift */,
);
path = Validators;
sourceTree = "<group>";
Expand Down Expand Up @@ -4749,6 +4757,7 @@
E702BDA4260108A700280682 /* ClientKeyValidator.swift */,
F9838D442631AD3700963483 /* BalanceChecker.swift */,
E755BF7126B40C90007AAB25 /* DateValidator.swift */,
A01DFBC42BB6F9FA00205881 /* ValidationError.swift */,
);
path = Validators;
sourceTree = "<group>";
Expand Down Expand Up @@ -6625,6 +6634,7 @@
E2028F5C2227F1930007FF8B /* Details.swift in Sources */,
5A988BF6265675ED0007F4C0 /* BoletoPaymentMethod.swift in Sources */,
F97FA030264B1367004AFD0C /* ListSectionFooterStyle.swift in Sources */,
A001676A2BC9567B0099A6AE /* AnalyticsValidationError.swift in Sources */,
A0BC64E628F062E400CED2A1 /* AdyenSessionAware.swift in Sources */,
0067E73D29658CB6005B8D76 /* FormImageItem.swift in Sources */,
E7D5311524462D87000046B4 /* FormButtonItem.swift in Sources */,
Expand All @@ -6647,6 +6657,7 @@
F96757BF27CF690600A16FB6 /* PartialPaymentError.swift in Sources */,
E7085B122628B29600D0153B /* BasePickerInputControl.swift in Sources */,
E700A72D2549E2F00012B64C /* BLIKPaymentMethod.swift in Sources */,
A01DFBC52BB6F9FA00205881 /* ValidationError.swift in Sources */,
E7085CB7262DE14A00D0153B /* AddressStyle.swift in Sources */,
81B001BC2A55B4040015BFA3 /* AddressLookupSearchViewController.swift in Sources */,
813EF9EC2A5FE48A00C65D15 /* FormPickerSearchViewController+EmptyView+Style.swift in Sources */,
Expand Down Expand Up @@ -7066,6 +7077,7 @@
E76CAC4E26BC0CC200117953 /* CardViewControllerItemsProvider.swift in Sources */,
E76EC680241125E0009C6E2F /* FormCardSecurityCodeItem.swift in Sources */,
E7166A5624EAD99F007CCDEF /* BinInfoProvider.swift in Sources */,
A01DFBC72BB6FBF800205881 /* CardValidationError.swift in Sources */,
E2028F5E222805E20007FF8B /* CardDetails.swift in Sources */,
F96A2882265F9C2B00E718BD /* GiftCardComponent+Extensions.swift in Sources */,
E9E3DAD1221EEE2100697074 /* CardExpiryDateValidator.swift in Sources */,
Expand Down
18 changes: 18 additions & 0 deletions Adyen/Analytics/Models/AnalyticsValidationError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// Copyright (c) 2024 Adyen N.V.
//
// This file is open source and available under the MIT license. See the LICENSE file for more info.
//

import Foundation

/// Protocol created specially for analytics related validation errors.
@_spi(AdyenInternal)
public protocol AnalyticsValidationError: ValidationError {

/// Code of the error for analytics.
var analyticsErrorCode: Int { get }

/// Message describing the error for analytics.
var analyticsErrorMessage: String { get }
}
5 changes: 5 additions & 0 deletions Adyen/UI/Form/Items/Text/FormTextItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ open class FormTextItem: FormValidatableValueItem<String>, InputViewRequiringFor
validator?.isValid(value) ?? true
}

override public func validationStatus() -> ValidationStatus? {
guard let statusValidator = validator as? StatusValidator else { return nil }
return statusValidator.validate(value)
}

/// The formatted text value.
@AdyenObservable("") internal var formattedValue: String

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,17 @@ open class FormValidatableValueItem<ValueType: Equatable>: FormValueItem<ValueTy
/// A message that is displayed when validation fails. Observable.
@AdyenObservable(nil) public var validationFailureMessage: String?

/// Closure that is triggered when there is a validation error.
public var onDidShowValidationError: ((ValidationError) -> Void)?

public func isValid() -> Bool {
AdyenAssertion.assertionFailure(message: "'\(#function)' needs to be implemented on '\(String(describing: Self.self))'")
return false
}

/// Checks the current validation status of the item.
public func validationStatus() -> ValidationStatus? {
AdyenAssertion.assertionFailure(message: "'\(#function)' needs to be implemented on '\(String(describing: Self.self))'")
return nil
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,20 @@ open class FormValidatableValueItemView<ValueType, ItemType: FormValidatableValu

private func hideAlertLabel(_ hidden: Bool, animated: Bool = true) {
guard hidden || alertLabel.text != nil else { return }
if !hidden {
triggerValidationErrorIfNeeded()
}
alertLabel.adyen.hide(animationKey: "hide_alertLabel", hidden: hidden, animated: animated)
}

// this function can be used to populate the alert label with the correct invalid message
// when we improve validation texts for all fields
private func triggerValidationErrorIfNeeded() {
guard let validationStatus = item.validationStatus(),
let error = validationStatus.validationError else { return }
item.onDidShowValidationError?(error)
}

internal func resetValidationStatus() {
hideAlertLabel(true, animated: false)
unhighlightSeparatorView()
Expand Down
1 change: 1 addition & 0 deletions Adyen/Validators/ClientKeyValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public enum ClientKeyError: Error, LocalizedError {
}

/// Validates a client key https://docs.adyen.com/user-management/client-side-authentication
@_spi(AdyenInternal)
public final class ClientKeyValidator: RegularExpressionValidator {

public init() {
Expand Down
1 change: 1 addition & 0 deletions Adyen/Validators/CountryCodeValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import Foundation

/// Validates two-character ISO standard 3166 2-character country codes.
@_spi(AdyenInternal)
public struct CountryCodeValidator: Validator {

private static let allCountryCodes = Set(Locale.isoRegionCodes)
Expand Down
1 change: 1 addition & 0 deletions Adyen/Validators/CurrencyCodeValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import Foundation

/// Validates three-character ISO standard 4217 currency codes.
@_spi(AdyenInternal)
public struct CurrencyCodeValidator: Validator {

private static let allCurrencyCodes = Set(Locale.isoCurrencyCodes)
Expand Down
1 change: 1 addition & 0 deletions Adyen/Validators/DateValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import Foundation

/// A generic validator for dates.
@_spi(AdyenInternal)
public final class DateValidator: Validator {

private let formatter: DateFormatter
Expand Down
1 change: 1 addition & 0 deletions Adyen/Validators/EmailValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import Foundation

/// Validates email addresses.
@_spi(AdyenInternal)
public class EmailValidator: RegularExpressionValidator {

public init() {
Expand Down
1 change: 1 addition & 0 deletions Adyen/Validators/IBANValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Foundation

/// Validates an IBAN (International Bank Account Number).
/// The input is expected to be sanitized.
@_spi(AdyenInternal)
public final class IBANValidator: Validator {

public init() {}
Expand Down
1 change: 1 addition & 0 deletions Adyen/Validators/LengthValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import Foundation

/// A generic validator that validates the length of a string.
@_spi(AdyenInternal)
open class LengthValidator: Validator {

/// The minimum length of the string.
Expand Down
1 change: 1 addition & 0 deletions Adyen/Validators/NumericStringValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import Foundation

/// Validates a numeric string.
@_spi(AdyenInternal)
open class NumericStringValidator: LengthValidator {

override public func isValid(_ value: String) -> Bool {
Expand Down
1 change: 1 addition & 0 deletions Adyen/Validators/PhoneNumberValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import Foundation

/// Validates a phone number.
@_spi(AdyenInternal)
public final class PhoneNumberValidator: RegularExpressionValidator {

public init() {
Expand Down
1 change: 1 addition & 0 deletions Adyen/Validators/RegularExpressionValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import Foundation

/// Validates a string using a regular expression.
@_spi(AdyenInternal)
public class RegularExpressionValidator: LengthValidator {

private let regularExpression: String
Expand Down
36 changes: 36 additions & 0 deletions Adyen/Validators/ValidationError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// Copyright (c) 2024 Adyen N.V.
//
// This file is open source and available under the MIT license. See the LICENSE file for more info.
//

import Foundation

@_spi(AdyenInternal)
public enum ValidationStatus {
case valid
case invalid(ValidationError)

public var isValid: Bool {
switch self {
case .valid:
return true
case .invalid:
return false
}
}

/// Convenience access to the validation error if invalid.
public var validationError: ValidationError? {
switch self {
case .valid:
return nil
case let .invalid(validationError):
return validationError
}
}
}

/// Protocol that defines the interface for validation errors.
@_spi(AdyenInternal)
public protocol ValidationError: LocalizedError {}
11 changes: 11 additions & 0 deletions Adyen/Validators/Validator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import Foundation

/// Validates a value.
@_spi(AdyenInternal)
public protocol Validator {

/// Returns a boolean value indicating if the given value is valid.
Expand All @@ -20,15 +21,25 @@ public protocol Validator {
/// - Parameter value: The value for which to determine the maximum length.
/// - Returns: The maximum length for the given value.
func maximumLength(for value: String) -> Int
}

@_spi(AdyenInternal)
public protocol StatusValidator: Validator {

/// Validates the value and returns the result.
/// - Parameter value: The value to validate.
/// - Returns: A `ValidationStatus` indicating the result.
func validate(_ value: String) -> ValidationStatus
}

/// Defines the `||` operator for two `Validator` operands.
@_spi(AdyenInternal)
public func || (lhs: Validator, rhs: Validator) -> Validator {
ORValidator(lhs: lhs, rhs: rhs)
}

/// Defines the `&&` operator for two `Validator` operands.
@_spi(AdyenInternal)
public func && (lhs: Validator, rhs: Validator) -> Validator {
ANDValidator(lhs: lhs, rhs: rhs)
}
Expand Down
14 changes: 9 additions & 5 deletions AdyenCard/Components/Card/CardComponent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,8 @@ public class CardComponent: PresentableComponent,
formViewController.cardDelegate = self
formViewController.title = paymentMethod.displayInformation(using: configuration.localizationParameters).title

formViewController.items.onDidTriggerEvent = { [weak self] infoType, target in
self?.sendInfoEvent(of: infoType, target: target)
formViewController.items.onDidTriggerInfoEvent = { [weak self] infoEventData in
self?.sendInfoEvent(with: infoEventData)
}

return formViewController
Expand All @@ -203,12 +203,16 @@ public class CardComponent: PresentableComponent,
private let panThrottler = Throttler(minimumDelay: CardComponent.Constant.secondsThrottlingDelay)
private let binThrottler = Throttler(minimumDelay: CardComponent.Constant.secondsThrottlingDelay)

private func sendInfoEvent(of type: AnalyticsEventInfo.InfoType, target: AnalyticsEventTarget) {
private func sendInfoEvent(with data: CardViewController.InfoEventData) {
var infoEvent = AnalyticsEventInfo(
component: paymentMethod.type.rawValue,
type: type
type: data.type
)
infoEvent.target = target
infoEvent.target = data.target
if let errorCode = data.error?.analyticsErrorCode {
infoEvent.validationErrorCode = String(errorCode)
}
infoEvent.validationErrorMessage = data.error?.analyticsErrorMessage
context.analyticsProvider?.add(info: infoEvent)
}
}
Expand Down
Loading

0 comments on commit bb25398

Please sign in to comment.