Skip to content

Commit

Permalink
Implement security code component UI (#489)
Browse files Browse the repository at this point in the history
* Add security code component

* Add some unit tests

* Add test cases for when the card scheme is not set

* Add UI tests for the security code component

* Hide linter warnings in GitHub UI

* Address review comments
  • Loading branch information
okhan-okbay-cko authored Oct 26, 2023
1 parent 76d9940 commit 285fcb7
Show file tree
Hide file tree
Showing 16 changed files with 828 additions and 254 deletions.
2 changes: 1 addition & 1 deletion .github/scripts/lintEditedFiles.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ if EDITED_FILES=$(git diff HEAD origin/main --name-only --diff-filter=d | grep "
if [ -z "$EDITED_FILES" ]; then
echo "No edited .swift files found."
else
swiftlint lint $EDITED_FILES | sed -E 's/^(.*):([0-9]+):([0-9]+): (warning|error|[^:]+): (.*)/::\4 title=Lint error,file=\1,line=\2,col=\3::\5\n\1:\2:\3/'
swiftlint lint $EDITED_FILES | sed -E -n 's/^(.*):([0-9]+):([0-9]+): error: (.*)/::error file=\1,line=\2,col=\3::\4\n\1:\2:\3/p'
fi
else
echo "No changes in .swift files found."
Expand Down
4 changes: 4 additions & 0 deletions Source/Core/Constants/AccessibilityIdentifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,8 @@ public enum AccessibilityIdentifiers {
static public let phoneNumber = "PhoneNumberInput"
}

public enum SecurityCodeComponent {
/// Identify security code component text field
static public let textField = "SecurityCodeTextField"
}
}
2 changes: 1 addition & 1 deletion Source/UI/PaymentForm/View/PaymentHeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import UIKit
import Checkout

public final class PaymentHeaderView: UIView {
final class PaymentHeaderView: UIView {
private var style: PaymentHeaderCellStyle?

private let supportedSchemes: [Card.Scheme]
Expand Down
5 changes: 3 additions & 2 deletions Source/UI/PaymentForm/View/SecurityCodeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,11 @@ extension SecurityCodeView: TextFieldViewDelegate {
}

func textFieldShouldChangeCharactersIn(textField: UITextField, replacementString string: String) {
guard let style = style else { return }
codeInputView.updateBorderColor(with: style.textfield.borderStyle.focusColor)
viewModel.updateInput(to: textField.text)
delegate?.update(securityCode: viewModel.cvv)

guard let style = style else { return }
codeInputView.updateBorderColor(with: style.textfield.borderStyle.focusColor)
}

func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// DefaultSecurityCodeFormStyle+Extension.swift
//
//
// Created by Okhan Okbay on 05/10/2023.
//

import Foundation

extension DefaultSecurityCodeFormStyle {
init(securityCodeComponentStyle: SecurityCodeComponentStyle) {
self.isMandatory = false
self.backgroundColor = .clear
self.title = nil
self.hint = nil
self.mandatory = nil
self.error = nil
self.textfield = DefaultTextField(textAlignment: securityCodeComponentStyle.textAlignment,
isHidden: false,
isSupportingNumericKeyboard: true,
text: securityCodeComponentStyle.text,
placeholder: securityCodeComponentStyle.placeholder,
textColor: securityCodeComponentStyle.textColor,
backgroundColor: .clear,
tintColor: securityCodeComponentStyle.tintColor,
width: .zero,
height: .zero,
font: securityCodeComponentStyle.font,
borderStyle: DefaultBorderStyle(cornerRadius: .zero,
borderWidth: .zero,
normalColor: .clear,
focusColor: .clear,
errorColor: .clear))

}
}
67 changes: 67 additions & 0 deletions Source/UI/SecurityCodeComponent/SecurityCodeComponent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//
// SecurityCodeComponent.swift
//
//
// Created by Okhan Okbay on 26/09/2023.
//

import Checkout
import UIKit

public final class SecurityCodeComponent: UIView {
private var view: SecurityCodeView!

private var configuration: SecurityCodeComponentConfiguration!
private var isSecurityCodeValid: ((Bool) -> Void)!
private var cardValidator: CardValidating!

override init(frame: CGRect) {
super.init(frame: frame)
}

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}

extension SecurityCodeComponent {
/**
Method to configure SecurityCodeComponent and get the validation updates
- configuration: See SecurityCodeComponentConfiguration documentation for the details
- isSecurityCodeValid: A boolean value that indicates if the security code that was input by the user is valid or not.
If a cardScheme is passed in the configuration, validation is being evaluated for the scheme. If no cardScheme is passed, then the security code is considered as valid for 3 and 4 digits.
*/
public func configure(with configuration: SecurityCodeComponentConfiguration,
isSecurityCodeValid: @escaping (Bool) -> Void) {
self.configuration = configuration
self.isSecurityCodeValid = isSecurityCodeValid

self.cardValidator = CardValidator(environment: configuration.environment.checkoutEnvironment)

let viewModel = SecurityCodeViewModel(cardValidator: cardValidator)
if let initialCardScheme = configuration.cardScheme {
viewModel.updateScheme(to: initialCardScheme)
}

let view = SecurityCodeView(viewModel: viewModel)
view.update(style: DefaultSecurityCodeFormStyle(securityCodeComponentStyle: configuration.style))
view.accessibilityIdentifier = AccessibilityIdentifiers.SecurityCodeComponent.textField
view.delegate = self

view.frame = bounds
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
addSubview(view)

self.view = view
}
}

extension SecurityCodeComponent: SecurityCodeViewDelegate {
func update(securityCode: String) {
guard !securityCode.isEmpty else {
isSecurityCodeValid(false)
return
}
isSecurityCodeValid(cardValidator.isValid(cvv: securityCode, for: configuration.cardScheme ?? .unknown))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// SecurityCodeConfiguration.swift
//
//
// Created by Okhan Okbay on 05/10/2023.
//

import Checkout
import UIKit

/**
Configures and styles the SecurityCodeComponent

- apiKey: The API Key you receive from checkout.com
- environment: Production or sandbox
- cardScheme: Optional card scheme
- If provided, card scheme's validation rules apply (e.g. VISA = 3 digits, American Express = 4 digits etc.)
- If not provided, security code is treated as valid for 3 and 4 digits
- style: Security Code Component wraps a text field in a secure way.
To style the inner properties like font, textColor etc, you must alter the style.
*/

public struct SecurityCodeComponentConfiguration {
let apiKey: String
let environment: Environment
public var style: SecurityCodeComponentStyle
public var cardScheme: Card.Scheme?

public init(apiKey: String,
environment: Environment,
style: SecurityCodeComponentStyle? = nil,
cardScheme: Card.Scheme? = nil) {
self.apiKey = apiKey
self.environment = environment
self.cardScheme = cardScheme

if let style = style {
self.style = style
} else {
self.style = .init(text: .init(),
font: FramesUIStyle.Font.inputLabel,
textAlignment: .natural,
textColor: FramesUIStyle.Color.textPrimary,
tintColor: FramesUIStyle.Color.textPrimary,
placeholder: nil)
}
}
}
38 changes: 38 additions & 0 deletions Source/UI/SecurityCodeComponent/SecurityCodeComponentStyle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// SecurityCodeComponentStyle.swift
//
//
// Created by Okhan Okbay on 05/10/2023.
//

import UIKit

/**
All the other UI relevant changes shall be done on the UIView instance of your project.
The reason that these properties are presented to be modified here is that
they are embedded in SecureDisplayView and shouldn't be reachable other than
via SecurityCodeComponentStyle.
*/

public struct SecurityCodeComponentStyle {
public let text: String
public let font: UIFont
public let textAlignment: NSTextAlignment
public let textColor: UIColor
public let tintColor: UIColor
public let placeholder: String?

public init(text: String,
font: UIFont,
textAlignment: NSTextAlignment,
textColor: UIColor,
tintColor: UIColor,
placeholder: String?) {
self.text = text
self.font = font
self.textAlignment = textAlignment
self.textColor = textColor
self.tintColor = tintColor
self.placeholder = placeholder
}
}
58 changes: 58 additions & 0 deletions Tests/UI/SecurityCodeComponent/SecurityCodeComponentTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// SecurityCodeComponentTests.swift
//
//
// Created by Okhan Okbay on 20/10/2023.
//

@testable import Frames
import XCTest

final class SecurityCodeComponentTests: XCTestCase {
let sut = SecurityCodeComponent()
var mockconfig = SecurityCodeComponentConfiguration(apiKey: "some_api_key", environment: .sandbox)

func test_whenUpdateIsCalledWith_thenISSecurityCodeValidCalledWithCorrectResult() {
let testMatrix: [(scheme: Card.Scheme?, securityCode: String, expectedResult: Bool)] = [
(.visa, "", false),
(.visa, "1", false),
(.visa, "12", false),
(.visa, "123", true),
(.visa, "1234", false),
(.americanExpress, "", false),
(.americanExpress, "1", false),
(.americanExpress, "12", false),
(.americanExpress, "123", false),
(.americanExpress, "1234", true),
(.unknown, "", false),
(.unknown, "1", false),
(.unknown, "12", false),
(.unknown, "123", true),
(.unknown, "1234", true),
(nil, "", false),
(nil, "1", false),
(nil, "12", false),
(nil, "123", true),
(nil, "1234", true),
]

testMatrix.forEach { testData in
mockconfig.cardScheme = testData.scheme
verify(securityCode: testData.securityCode, expectedResult: testData.expectedResult)
}
}

func verify(securityCode: String,
expectedResult: Bool,
file: StaticString = #file,
line: UInt = #line) {
sut.configure(with: mockconfig) { [weak self] isSecurityCodeValid in
XCTAssertEqual(expectedResult,
isSecurityCodeValid,
"Security code: \(securityCode) Card Scheme: \(self?.mockconfig.cardScheme?.rawValue ?? "nil") \n Expected: \(expectedResult) but found: \(isSecurityCodeValid)",
file: file,
line: line)
}
sut.update(securityCode: securityCode)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ let tokenableTestCards: [TestCard] = [
]

/**
These are luhn numbers that are just 1 character less with the least character count of the relevant card schemes
These are luhn numbers that are just 1 character less than the min character count of the relevant card schemes
For example, Visa cards must be at least 13 characters and must start with 44.
So, we needed a luhn number that starts with 4 and is 12 digits.
To see that we check the character count beyond the luhn verifications.
Expand Down
Loading

0 comments on commit 285fcb7

Please sign in to comment.