diff --git a/.gitignore b/.gitignore index 3b29812..cf206b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,34 @@ +*.DS_Store .DS_Store -/.build -/Packages -/*.xcodeproj -xcuserdata/ +*.generated.swift + +## Build generated +build/ DerivedData/ -.swiftpm/config/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc +xcuserdata/ +xcshareddata/ +/.swiftpm +*.xcuserstate + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Other +*.moved-aside +*.xccheckout +*.xcscmblueprint + +# Swift Package Manager +.build/ +*Package.resolved +Package.resolved* + +# SwiftLint +lintOutput.json diff --git a/ExampleApp/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ExampleApp/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index da2cfba..0000000 --- a/ExampleApp/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,14 +0,0 @@ -{ - "pins" : [ - { - "identity" : "viewinspector", - "kind" : "remoteSourceControl", - "location" : "https://github.com/nalexn/ViewInspector", - "state" : { - "branch" : "master", - "revision" : "4effbd9143ab797eb60d2f32d4265c844c980946" - } - } - ], - "version" : 2 -} diff --git a/ExampleApp/ExampleApp/AppDelegate.swift b/ExampleApp/ExampleApp/AppDelegate.swift index 115016c..e6ff82c 100644 --- a/ExampleApp/ExampleApp/AppDelegate.swift +++ b/ExampleApp/ExampleApp/AppDelegate.swift @@ -13,8 +13,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - return true } } - diff --git a/ExampleApp/ExampleApp/InputFields/SecureInputField.swift b/ExampleApp/ExampleApp/InputFields/SecureInputField.swift index d821bcd..9ceddc3 100644 --- a/ExampleApp/ExampleApp/InputFields/SecureInputField.swift +++ b/ExampleApp/ExampleApp/InputFields/SecureInputField.swift @@ -11,7 +11,7 @@ import FormView struct SecureInputField: View { let title: LocalizedStringKey let text: Binding - let failedRules: [TextValidationRule] + let failedRules: [ValidationRule] @FocusState private var isFocused: Bool @State private var isSecure = true @@ -24,8 +24,8 @@ struct SecureInputField: View { eyeImage } .background(Color.white) - if failedRules.isEmpty == false { - Text(failedRules[0].message) + if failedRules.isEmpty == false, let message = failedRules[0].message { + Text(message) .font(.system(size: 12, weight: .semibold)) .foregroundColor(.red) } diff --git a/ExampleApp/ExampleApp/InputFields/TextInputField.swift b/ExampleApp/ExampleApp/InputFields/TextInputField.swift index 992299f..d39d1a4 100644 --- a/ExampleApp/ExampleApp/InputFields/TextInputField.swift +++ b/ExampleApp/ExampleApp/InputFields/TextInputField.swift @@ -10,15 +10,15 @@ import FormView struct TextInputField: View { let title: LocalizedStringKey - let text: Binding - let failedRules: [TextValidationRule] + @Binding var text: String + let failedRules: [ValidationRule] var body: some View { VStack(alignment: .leading) { - TextField(title, text: text) + TextField(title, text: $text) .background(Color.white) - if failedRules.isEmpty == false { - Text(failedRules[0].message) + if let errorMessage = failedRules.first?.message { + Text(errorMessage) .font(.system(size: 12, weight: .semibold)) .foregroundColor(.red) } @@ -26,4 +26,14 @@ struct TextInputField: View { } .frame(height: 50) } + + init( + title: LocalizedStringKey, + text: Binding, + failedRules: [ValidationRule] + ) { + self.title = title + self._text = text + self.failedRules = failedRules + } } diff --git a/ExampleApp/ExampleApp/MyRule.swift b/ExampleApp/ExampleApp/MyRule.swift index 90577bc..1372ce1 100644 --- a/ExampleApp/ExampleApp/MyRule.swift +++ b/ExampleApp/ExampleApp/MyRule.swift @@ -6,10 +6,10 @@ import FormView -extension TextValidationRule { +extension ValidationRule { static var myRule: Self { - TextValidationRule(message: "Shold contain T") { - $0.contains("T") + Self.custom { + return $0.contains("T") ? nil : "Should contain T" } } } diff --git a/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift b/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift index c4513fa..9e0427c 100644 --- a/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift +++ b/ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift @@ -13,50 +13,46 @@ struct ContentView: View { var body: some View { FormView( - validate: .never, + validate: .onFieldValueChanged, hideError: .onValueChanged ) { proxy in FormField( value: $viewModel.name, - rules: [ - TextValidationRule.noSpecialCharacters(message: "No spec chars"), - .notEmpty(message: "Name empty"), - .myRule - ] + rules: viewModel.nameValidationRules ) { failedRules in TextInputField(title: "Name", text: $viewModel.name, failedRules: failedRules) } + .disabled(viewModel.isLoading) FormField( value: $viewModel.age, - rules: [ - TextValidationRule.digitsOnly(message: "Digits only"), - .maxLength(count: 2, message: "Max length 2") - ] + rules: viewModel.ageValidationRules ) { failedRules in TextInputField(title: "Age", text: $viewModel.age, failedRules: failedRules) } + .disabled(viewModel.isLoading) FormField( value: $viewModel.pass, - rules: [ - TextValidationRule.atLeastOneDigit(message: "One digit"), - .atLeastOneLetter(message: "One letter"), - .notEmpty(message: "Pass not empty") - ] + rules: viewModel.passValidationRules ) { failedRules in SecureInputField(title: "Password", text: $viewModel.pass, failedRules: failedRules) } + .disabled(viewModel.isLoading) FormField( value: $viewModel.confirmPass, - rules: [ - TextValidationRule.equalTo(value: viewModel.pass, message: "Not equal to pass"), - .notEmpty(message: "Confirm pass not empty") - ] + rules: viewModel.confirmPassValidationRules ) { failedRules in SecureInputField(title: "Confirm Password", text: $viewModel.confirmPass, failedRules: failedRules) } + .disabled(viewModel.isLoading) + if viewModel.isLoading { + ProgressView() + } Button("Validate") { - print("Form is valid: \(proxy.validate())") + Task { + print("Form is valid: \(await proxy.validate())") + } } + .disabled(viewModel.isLoading) } .padding(.horizontal, 16) .padding(.top, 40) diff --git a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift index a05e288..fcce647 100644 --- a/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift +++ b/ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift @@ -6,18 +6,69 @@ // import SwiftUI +import FormView class ContentViewModel: ObservableObject { @Published var name: String = "" @Published var age: String = "" @Published var pass: String = "" @Published var confirmPass: String = "" + @Published var isLoading = false + + var nameValidationRules: [ValidationRule] = [] + var ageValidationRules: [ValidationRule] = [] + var passValidationRules: [ValidationRule] = [] + var confirmPassValidationRules: [ValidationRule] = [] private let coordinator: ContentCoordinator init(coordinator: ContentCoordinator) { self.coordinator = coordinator print("init ContentViewModel") + + setupValidationRules() + } + + private func setupValidationRules() { + nameValidationRules = [ + ValidationRule.notEmpty(message: "Name empty"), + ValidationRule.noSpecialCharacters(message: "No spec chars"), + ValidationRule.myRule, + ValidationRule.external { [weak self] in await self?.availabilityCheckAsync($0) } + ] + + ageValidationRules = [ + ValidationRule.digitsOnly(message: "Digits only"), + ValidationRule.maxLength(count: 2, message: "Max length 2") + ] + + passValidationRules = [ + ValidationRule.atLeastOneDigit(message: "One digit"), + ValidationRule.atLeastOneLetter(message: "One letter"), + ValidationRule.notEmpty(message: "Pass not empty") + ] + + confirmPassValidationRules = [ + ValidationRule.notEmpty(message: "Confirm pass not empty"), + ValidationRule.custom { [weak self] in + return $0 == self?.pass ? nil : "Not equal to pass" + } + ] + } + + @MainActor + private func availabilityCheckAsync(_ value: String) async -> String? { + print(#function) + + isLoading = true + + try? await Task.sleep(nanoseconds: 2_000_000_000) + + let isAvailable = Bool.random() + + isLoading = false + + return isAvailable ? nil : "Not available" } deinit { diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index da2cfba..0000000 --- a/Package.resolved +++ /dev/null @@ -1,14 +0,0 @@ -{ - "pins" : [ - { - "identity" : "viewinspector", - "kind" : "remoteSourceControl", - "location" : "https://github.com/nalexn/ViewInspector", - "state" : { - "branch" : "master", - "revision" : "4effbd9143ab797eb60d2f32d4265c844c980946" - } - } - ], - "version" : 2 -} diff --git a/Package.swift b/Package.swift index f92de2c..cb3a288 100644 --- a/Package.swift +++ b/Package.swift @@ -11,17 +11,16 @@ let package = Package( products: [ .library( name: "FormView", - targets: ["FormView"]) + targets: ["FormView"] + ) ], dependencies: [ - .package(url: "https://github.com/nalexn/ViewInspector", branch: "master") + .package(url: "https://github.com/nalexn/ViewInspector", exact: "0.10.1") ], targets: [ .target( name: "FormView", - dependencies: []), - .testTarget( - name: "FormViewTests", - dependencies: ["FormView", "ViewInspector"]) + dependencies: [] + ) ] ) diff --git a/README.md b/README.md index 85ccdee..cf76625 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,92 @@ A banch of predefind rules for text validation is available via `TextValidationR * equalTo - value equal to another value. Useful for password confirmation. * etc... +### Outer Validation Rules +If you need to display validation errors from external services (e.g., a backend), follow these steps: +1. Create an `OuterValidationRule` enum: +```swift +enum OuterValidationRule { + case duplicateName + + var message: String { + switch self { + case .duplicateName: + return "This name already exists" + } + } +} +``` + +2. Update the text field component: +```swift +struct TextInputField: View { + let title: LocalizedStringKey + @Binding var text: String + let failedRules: [TextValidationRule] + @Binding var outerRules: [OuterValidationRule] + + var body: some View { + VStack(alignment: .leading) { + TextField(title, text: $text) + .background(Color.white) + if let errorMessage = getErrorMessage() { + Text(errorMessage) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.red) + } + Spacer() + } + .frame(height: 50) + .onChange(of: text) { _ in + outerRules = [] + } + } + + private func getErrorMessage() -> String? { + if let message = failedRules.first?.message { + return message + } else if let message = outerRules.first?.message { + return message + } else { + return nil + } + } + + init( + title: LocalizedStringKey, + text: Binding, + failedRules: [TextValidationRule], + outerRules: Binding<[OuterValidationRule]> = .constant([]) + ) { + self.title = title + self._text = text + self.failedRules = failedRules + self._outerRules = outerRules + } +} +``` +3. Update the text field initialization in your view: +```swift +TextInputField( + title: "Name", + text: $viewModel.name, + failedRules: failedRules, + outerRules: $viewModel.nameOuterRules +) +``` + +4. In your ViewModel, declare a `@Published` property of type `OuterValidationRule` and update its rules as needed: +```swift +class ContentViewModel: ObservableObject { + @Published var nameOuterRules: [OuterValidationRule] = [] + + func applyNameOuterRules() { + nameOuterRules = [.duplicateName] + } +} +``` + + ### Implementation Details FormView doesn't use any external dependencies. diff --git a/Sources/FormView/FormField.swift b/Sources/FormView/FormField.swift index a1e8d91..0fa9486 100644 --- a/Sources/FormView/FormField.swift +++ b/Sources/FormView/FormField.swift @@ -7,11 +7,11 @@ import SwiftUI -public struct FormField: View where Value == Rule.Value { - @Binding private var value: Value - @ViewBuilder private let content: ([Rule]) -> Content +public struct FormField: View { + @Binding private var value: String + @ViewBuilder private let content: ([ValidationRule]) -> Content - @State private var failedValidationRules: [Rule] = [] + @State private var failedValidationRules: [ValidationRule] = [] // Fields Focus @FocusState private var isFocused: Bool @@ -19,14 +19,14 @@ public struct FormField: V @Environment(\.focusedFieldId) var currentFocusedFieldId // ValidateInput - private let validator: FieldValidator + private let validator: FieldValidator @Environment(\.errorHideBehaviour) var errorHideBehaviour @Environment(\.validationBehaviour) var validationBehaviour public init( - value: Binding, - rules: [Rule] = [], - @ViewBuilder content: @escaping ([Rule]) -> Content + value: Binding, + rules: [ValidationRule] = [], + @ViewBuilder content: @escaping ([ValidationRule]) -> Content ) { self._value = value self.content = content @@ -46,7 +46,7 @@ public struct FormField: V value: [ // Замыкание для каждого филда вызывается FormValidator'ом из FormView для валидации по требованию FieldState(id: id, isFocused: isFocused) { - let failedRules = validator.validate(value: value) + let failedRules = await validator.validate(value: value, isNeedToCheckExternal: true) failedValidationRules = failedRules return failedRules.isEmpty @@ -57,23 +57,27 @@ public struct FormField: V // Fields Validation .onChange(of: value) { newValue in - if errorHideBehaviour == .onValueChanged { - failedValidationRules = .empty - } - - if validationBehaviour == .onFieldValueChanged { - failedValidationRules = validator.validate(value: newValue) + Task { @MainActor in + if errorHideBehaviour == .onValueChanged { + failedValidationRules = .empty + } + + if validationBehaviour == .onFieldValueChanged { + failedValidationRules = await validator.validate(value: newValue, isNeedToCheckExternal: false) + } } } .onChange(of: isFocused) { newValue in - if errorHideBehaviour == .onFocusLost && newValue == false { - failedValidationRules = .empty - } else if errorHideBehaviour == .onFocus && newValue == true { - failedValidationRules = .empty - } - - if validationBehaviour == .onFieldFocusLost && newValue == false { - failedValidationRules = validator.validate(value: value) + Task { @MainActor in + if errorHideBehaviour == .onFocusLost && newValue == false { + failedValidationRules = .empty + } else if errorHideBehaviour == .onFocus && newValue == true { + failedValidationRules = .empty + } + + if validationBehaviour == .onFieldFocusLost && newValue == false { + failedValidationRules = await validator.validate(value: value, isNeedToCheckExternal: false) + } } } } diff --git a/Sources/FormView/FormView.swift b/Sources/FormView/FormView.swift index 035c708..a33fa83 100644 --- a/Sources/FormView/FormView.swift +++ b/Sources/FormView/FormView.swift @@ -31,19 +31,24 @@ private class FormStateHandler: ObservableObject { currentFocusedFieldId = focusedField?.id ?? .empty // Замыкание onValidateRun вызывается методом validate() FormValidator'a. - formValidator.onValidateRun = { [weak self] focusOnFirstFailedField in + formValidator.onValidateRun = { @MainActor [weak self] focusOnFirstFailedField in guard let self else { return false } - let resutls = newStates.map { $0.onValidate() } - + var results: [Bool] = [] + + for newState in newStates { + let result = await newState.onValidate() + results.append(result) + } + // Фокус на первом зафейленом филде. - if let index = resutls.firstIndex(of: false), focusOnFirstFailedField { + if let index = results.firstIndex(of: false), focusOnFirstFailedField { currentFocusedFieldId = fieldStates[index].id } - return resutls.allSatisfy { $0 } + return results.allSatisfy { $0 } } } diff --git a/Sources/FormView/Preference/FieldState.swift b/Sources/FormView/Preference/FieldState.swift index 41b03c1..c71b427 100644 --- a/Sources/FormView/Preference/FieldState.swift +++ b/Sources/FormView/Preference/FieldState.swift @@ -10,7 +10,7 @@ import SwiftUI struct FieldState { var id: String var isFocused: Bool - var onValidate: () -> Bool + var onValidate: () async -> Bool } extension FieldState: Equatable { diff --git a/Sources/FormView/Validation/Rules/TextValidationRule.swift b/Sources/FormView/Validation/Rules/TextValidationRule.swift deleted file mode 100644 index 08b1a1d..0000000 --- a/Sources/FormView/Validation/Rules/TextValidationRule.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// TextValidationRule.swift -// -// -// Created by Maxim Aliev on 27.01.2023. -// - -import Foundation - -public struct TextValidationRule: ValidationRule { - public let message: String - - private let checkClosure: (String) -> Bool - - public init(message: String, checkClosure: @escaping (String) -> Bool) { - self.checkClosure = checkClosure - self.message = message - } - - public func check(value: String) -> Bool { - return checkClosure(value) - } -} - -extension TextValidationRule { - public static func notEmpty(message: String) -> Self { - TextValidationRule(message: message) { - $0.isEmpty == false - } - } - - public static func atLeastOneLowercaseLetter(message: String) -> Self { - TextValidationRule(message: message) { - $0.rangeOfCharacter(from: CharacterSet.lowercaseLetters) != nil - } - } - - public static func atLeastOneUppercaseLetter(message: String) -> Self { - TextValidationRule(message: message) { - $0.rangeOfCharacter(from: CharacterSet.uppercaseLetters) != nil - } - } - - public static func atLeastOneDigit(message: String) -> Self { - TextValidationRule(message: message) { - $0.rangeOfCharacter(from: CharacterSet.decimalDigits) != nil - } - } - - public static func atLeastOneLetter(message: String) -> Self { - TextValidationRule(message: message) { - $0.rangeOfCharacter(from: CharacterSet.letters) != nil - } - } - - public static func digitsOnly(message: String) -> Self { - TextValidationRule(message: message) { - CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: $0)) - } - } - - public static func lettersOnly(message: String) -> Self { - TextValidationRule(message: message) { - CharacterSet.letters.isSuperset(of: CharacterSet(charactersIn: $0)) - } - } - - public static func atLeastOneSpecialCharacter(message: String) -> Self { - TextValidationRule(message: message) { - $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) != nil - } - } - - public static func noSpecialCharacters(message: String) -> Self { - TextValidationRule(message: message) { - $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) == nil - } - } - - public static func email(message: String) -> Self { - TextValidationRule(message: message) { - NSPredicate(format: "SELF MATCHES %@", "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}") - .evaluate(with: $0) - } - } - - public static func notRecurringPincode(message: String) -> Self { - TextValidationRule(message: message) { - $0.range(of: "([0-9])\\1\\1\\1", options: .regularExpression) == nil - } - } - - public static func minLength(count: Int, message: String) -> Self { - TextValidationRule(message: message) { - $0.count >= count - } - } - - public static func maxLength(count: Int, message: String) -> Self { - TextValidationRule(message: message) { - $0.count <= count - } - } - - public static func regex(value: String, message: String) -> Self { - TextValidationRule(message: message) { - NSPredicate(format: "SELF MATCHES %@", value) - .evaluate(with: $0) - } - } - - public static func equalTo(value: String, message: String) -> Self { - TextValidationRule(message: message) { - $0 == value - } - } -} diff --git a/Sources/FormView/Validation/Rules/ValidationRule.swift b/Sources/FormView/Validation/Rules/ValidationRule.swift index 2019334..5f67b7f 100644 --- a/Sources/FormView/Validation/Rules/ValidationRule.swift +++ b/Sources/FormView/Validation/Rules/ValidationRule.swift @@ -2,13 +2,123 @@ // ValidationRule.swift // // -// Created by Maxim Aliev on 29.01.2023. +// Created by Maxim Aliev on 27.01.2023. // import Foundation +import SwiftUI -public protocol ValidationRule { - associatedtype Value +public class ValidationRule { + public var message: String? + public let isExternal: Bool - func check(value: Value) -> Bool + private let checkClosure: (String) async -> String? + + internal required init(isExternal: Bool, checkClosure: @escaping (String) async -> String?) { + self.checkClosure = checkClosure + self.isExternal = isExternal + } + + public func check(value: String) async -> Bool { + let message = await checkClosure(value) + self.message = message + + return message == nil + } +} + +extension ValidationRule { + public static func custom(checkClosure: @escaping (String) async -> String?) -> Self { + return Self(isExternal: false, checkClosure: checkClosure) + } + + public static func external(checkClosure: @escaping (String) async -> String?) -> Self { + return Self(isExternal: true, checkClosure: checkClosure) + } + + public static func notEmpty(message: String) -> Self { + return Self(isExternal: false) { + return $0.isEmpty == false ? nil : message + } + } + + public static func atLeastOneLowercaseLetter(message: String) -> Self { + return Self(isExternal: false) { + return $0.rangeOfCharacter(from: CharacterSet.lowercaseLetters) != nil ? nil : message + } + } + + public static func atLeastOneUppercaseLetter(message: String) -> Self { + return Self(isExternal: false) { + $0.rangeOfCharacter(from: CharacterSet.uppercaseLetters) != nil ? nil : message + } + } + + public static func atLeastOneDigit(message: String) -> Self { + return Self(isExternal: false) { + $0.rangeOfCharacter(from: CharacterSet.decimalDigits) != nil ? nil : message + } + } + + public static func atLeastOneLetter(message: String) -> Self { + return Self(isExternal: false) { + $0.rangeOfCharacter(from: CharacterSet.letters) != nil ? nil : message + } + } + + public static func digitsOnly(message: String) -> Self { + return Self(isExternal: false) { + CharacterSet.decimalDigits.isSuperset(of: CharacterSet(charactersIn: $0)) ? nil : message + } + } + + public static func lettersOnly(message: String) -> Self { + return Self(isExternal: false) { + CharacterSet.letters.isSuperset(of: CharacterSet(charactersIn: $0)) ? nil : message + } + } + + public static func atLeastOneSpecialCharacter(message: String) -> Self { + return Self(isExternal: false) { + $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) != nil ? nil : message + } + } + + public static func noSpecialCharacters(message: String) -> Self { + return Self(isExternal: false) { + $0.range(of: ".*[^A-Za-zА-Яа-яё0-9 ].*", options: .regularExpression) == nil ? nil : message + } + } + + public static func email(message: String) -> Self { + return Self(isExternal: false) { + NSPredicate(format: "SELF MATCHES %@", "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}") + .evaluate(with: $0) ? nil : message + } + } + + public static func notRecurringPincode(message: String) -> Self { + return Self(isExternal: false) { + $0.range(of: "([0-9])\\1\\1\\1", options: .regularExpression) == nil ? nil : message + } + } + + public static func minLength(count: Int, message: String) -> Self { + return Self(isExternal: false) { + $0.count >= count ? nil : message + } + } + + public static func maxLength(count: Int, message: String) -> Self { + return Self(isExternal: false) { + $0.count <= count ? nil : message + } + } + + public static func regex(value: String, message: String) -> Self { + return Self(isExternal: false) { + NSPredicate(format: "SELF MATCHES %@", value) + .evaluate(with: $0) ? nil : message + } + } } diff --git a/Sources/FormView/Validation/Validators/FieldValidator.swift b/Sources/FormView/Validation/Validators/FieldValidator.swift index 36f1752..ec7d609 100644 --- a/Sources/FormView/Validation/Validators/FieldValidator.swift +++ b/Sources/FormView/Validation/Validators/FieldValidator.swift @@ -7,16 +7,22 @@ import SwiftUI -struct FieldValidator { - private let rules: [Rule] +struct FieldValidator { + private let rules: [ValidationRule] - init(rules: [Rule]) { + init(rules: [ValidationRule]) { self.rules = rules } - func validate(value: Rule.Value) -> [Rule] { - return rules.filter { - $0.check(value: value) == false + func validate(value: String, isNeedToCheckExternal: Bool) async -> [ValidationRule] { + var failedRules: [ValidationRule] = [] + + for rule in rules where rule.isExternal == false || isNeedToCheckExternal { + if await rule.check(value: value) == false { + failedRules.append(rule) + } } + + return failedRules } } diff --git a/Sources/FormView/Validation/Validators/FormValidator.swift b/Sources/FormView/Validation/Validators/FormValidator.swift index b493a70..25f775e 100644 --- a/Sources/FormView/Validation/Validators/FormValidator.swift +++ b/Sources/FormView/Validation/Validators/FormValidator.swift @@ -8,18 +8,18 @@ import Foundation public struct FormValidator { - var onValidateRun: ((Bool) -> Bool)? + var onValidateRun: ((Bool) async -> Bool)? public init() { onValidateRun = nil } - public func validate(focusOnFirstFailedField: Bool = true) -> Bool { + public func validate(focusOnFirstFailedField: Bool = true) async -> Bool { guard let onValidateRun else { assertionFailure("onValidateRun closure not found") return false } - return onValidateRun(focusOnFirstFailedField) + return await onValidateRun(focusOnFirstFailedField) } } diff --git a/Tests/FormViewTests/FormViewTests.swift b/Tests/FormViewTests/FormViewTests.swift deleted file mode 100644 index 726a10b..0000000 --- a/Tests/FormViewTests/FormViewTests.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// FormViewTests.swift -// -// -// Created by Maxim Aliev on 18.02.2023. -// - -import SwiftUI -import XCTest -import ViewInspector -import Combine -@testable import FormView - -final class FormViewTests: XCTestCase { - func testPreventInvalidInput() throws { - var text1 = "" - var text2 = "" - let sut = InspectionWrapperView( - wrapped: FormView { - ScrollView { - FormField( - value: Binding(get: { text1 }, set: { text1 = $0 }), - validationRules: [.digitsOnly] - ) - .id(1) - FormField(value: Binding(get: { text2 }, set: { text2 = $0 })) - .id(2) - } - } - ) - - let exp = sut.inspection.inspect { view in - let scrollView = try view.find(ViewType.ScrollView.self) - let textField1 = try view.find(viewWithId: 1).textField() - - try scrollView.callOnSubmit() - try textField1.callOnChange(newValue: "New Focus Field", index: 1) - try textField1.callOnChange(newValue: "123") - XCTAssertEqual(try textField1.input(), "123") - - text1 = "123" - try textField1.callOnChange(newValue: "123_A") - XCTAssertEqual(try textField1.input(), text1) - } - - ViewHosting.host(view: sut) - wait(for: [exp], timeout: 0.1) - } - - func testSubmitTextField() throws { - var text1 = "" - var text2 = "" - let sut = InspectionWrapperView( - wrapped: FormView { - ScrollView { - FormField( - value: Binding(get: { text1 }, set: { text1 = $0 }), - validationRules: [.digitsOnly] - ) - .id(1) - FormField(value: Binding(get: { text2 }, set: { text2 = $0 })) - .id(2) - } - } - ) - - let exp = sut.inspection.inspect { view in - let scrollView = try view.find(ViewType.ScrollView.self) - let textField1 = try view.find(viewWithId: 1).textField() -// let formField2 = try view.find(viewWithId: 2).view(FormField.self).actualView() - - try scrollView.callOnSubmit() - try textField1.callOnChange(newValue: "field2", index: 1) - -// XCTAssertEqual(formField2.focusField, "field2") - XCTAssertTrue(true) - } - - ViewHosting.host(view: sut.environment(\.focusField, "field1")) - wait(for: [exp], timeout: 0.1) - } - - func testFocusNextField() throws { - var fieldStates = [FieldState(id: "1", isFocused: false), FieldState(id: "2", isFocused: false)] - - var nextFocusField = fieldStates.focusNextField(currentFocusField: "") - XCTAssertEqual(nextFocusField, "1") - - nextFocusField = fieldStates.focusNextField(currentFocusField: "1") - XCTAssertEqual(nextFocusField, "2") - } -} diff --git a/Tests/FormViewTests/InspectionHelper/Inspection.swift b/Tests/FormViewTests/InspectionHelper/Inspection.swift deleted file mode 100644 index 77b5b12..0000000 --- a/Tests/FormViewTests/InspectionHelper/Inspection.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Inspection.swift -// -// -// Created by Maxim Aliev on 18.02.2023. -// - -import Combine -import ViewInspector -@testable import FormView - -final class Inspection { - let notice = PassthroughSubject() - var callbacks: [UInt: (V) -> Void] = [:] - - func visit(_ view: V, _ line: UInt) { - if let callback = callbacks.removeValue(forKey: line) { - callback(view) - } - } -} - -extension Inspection: InspectionEmissary { } diff --git a/Tests/FormViewTests/InspectionHelper/InspectionWrapperView.swift b/Tests/FormViewTests/InspectionHelper/InspectionWrapperView.swift deleted file mode 100644 index 6fe6878..0000000 --- a/Tests/FormViewTests/InspectionHelper/InspectionWrapperView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// InspectionWrapperView.swift -// -// -// Created by Maxim Aliev on 18.02.2023. -// - -import SwiftUI - -struct InspectionWrapperView: View { - let inspection = Inspection() - var wrapped: V - - init(wrapped: V) { - self.wrapped = wrapped - } - - var body: some View { - wrapped - .onReceive(inspection.notice) { - inspection.visit(self, $0) - } - } -} diff --git a/Tests/FormViewTests/Validation/TextValidationRuleTests.swift b/Tests/FormViewTests/Validation/TextValidationRuleTests.swift deleted file mode 100644 index b6dc3f5..0000000 --- a/Tests/FormViewTests/Validation/TextValidationRuleTests.swift +++ /dev/null @@ -1,79 +0,0 @@ -// -// TextValidationRuleTests.swift -// -// -// Created by Maxim Aliev on 07.02.2023. -// - -import XCTest -@testable import FormView - -final class TextValidationRuleTests: XCTestCase { - func testIgnoreEmpty() throws { - try test(textRule: .digitsOnly, trueString: "", falseString: "1234 A") - } - - func testNotEmpty() throws { - try test(textRule: .notEmpty, trueString: "Not empty", falseString: "") - } - - func testMinLength() throws { - try test(textRule: .minLength(4), trueString: "1234", falseString: "123") - } - - func testMaxLength() throws { - try test(textRule: .maxLength(4), trueString: "1234", falseString: "123456") - } - - func testAtLeastOneDigit() throws { - try test(textRule: .atLeastOneDigit, trueString: "Digit 5", falseString: "No Digits") - } - - func testAtLeastOneLetter() throws { - try test(textRule: .atLeastOneLetter, trueString: "1234 A", falseString: "1234") - } - - func testDigitsOnly() throws { - try test(textRule: .digitsOnly, trueString: "1234", falseString: "1234 A") - } - - func testLettersOnly() throws { - try test(textRule: .lettersOnly, trueString: "Letters", falseString: "Digit 5") - } - - func testAtLeastOneLowercaseLetter() throws { - try test(textRule: .atLeastOneLowercaseLetter, trueString: "LOWEr", falseString: "UPPER") - } - - func testAtLeastOneUppercaseLetter() throws { - try test(textRule: .atLeastOneUppercaseLetter, trueString: "Upper", falseString: "lower") - } - - func testAtLeastOneSpecialCharacter() throws { - try test(textRule: .atLeastOneSpecialCharacter, trueString: "Special %", falseString: "No special") - } - - func testNoSpecialCharacters() throws { - try test(textRule: .noSpecialCharacters, trueString: "No special", falseString: "Special %") - } - - func testEmail() throws { - try test(textRule: .email, trueString: "alievmaxx@gmail.com", falseString: "alievmaxx@.com") - } - - func testNotRecurringPincode() throws { - try test(textRule: .notRecurringPincode, trueString: "1234", falseString: "5555") - } - - func testRegex() throws { - let dateRegex = "(\\d{2}).(\\d{2}).(\\d{4})" - try test(textRule: .regex(dateRegex), trueString: "21.12.2000", falseString: "21..2000") - } - - private func test(textRule: TextValidationRule, trueString: String, falseString: String) throws { - let isPassed = textRule.check(value: trueString) - let isFailed = textRule.check(value: falseString) == false - - XCTAssertTrue(isPassed && isFailed) - } -} diff --git a/Tests/FormViewTests/Validation/ValidatorTests.swift b/Tests/FormViewTests/Validation/ValidatorTests.swift deleted file mode 100644 index a2f5d59..0000000 --- a/Tests/FormViewTests/Validation/ValidatorTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// ValidatorTests.swift -// -// -// Created by Maxim Aliev on 19.02.2023. -// - -import SwiftUI -import XCTest -@testable import FormView - -final class ValidatorTests: XCTestCase { - func testValidator() throws { - var text: String = "" - var failedValidationRules: [TextValidationRule] = [] - - let validator = FieldValidator( - value: Binding(get: { text }, set: { text = $0 }), - validationRules: [.digitsOnly], - inputRules: [.maxLength(4)], - failedValidationRules: Binding(get: { failedValidationRules }, set: { failedValidationRules = $0 }) - ) - - validator.value = "1" - validator.validate() - XCTAssertTrue(failedValidationRules.isEmpty) - - validator.value = "12_A" - XCTAssertEqual(failedValidationRules, [.digitsOnly]) - - validator.value = "12345" - let failedInputRules = validator.validateInput() - XCTAssertEqual(failedInputRules, [.maxLength(4)]) - } -}