diff --git a/Package.swift b/Package.swift index 9c15384..ba685c9 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,11 @@ let package = Package( ], products: [ .library(name: "SpeziViews", targets: ["SpeziViews"]), - .library(name: "SpeziPersonalInfo", targets: ["SpeziPersonalInfo"]) + .library(name: "SpeziPersonalInfo", targets: ["SpeziPersonalInfo"]), + .library(name: "SpeziValidation", targets: ["SpeziValidation"]) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-collections.git", .upToNextMajor(from: "1.0.4")) ], targets: [ .target( @@ -31,11 +35,17 @@ let package = Package( .target(name: "SpeziViews") ] ), + .target( + name: "SpeziValidation", + dependencies: [ + .target(name: "SpeziViews"), + .product(name: "OrderedCollections", package: "swift-collections") + ] + ), .testTarget( name: "SpeziViewsTests", dependencies: [ - .target(name: "SpeziViews"), - .target(name: "SpeziPersonalInfo") + .target(name: "SpeziViews") ] ) ] diff --git a/Sources/SpeziValidation/Configuration/ValidationDebounceDuration.swift b/Sources/SpeziValidation/Configuration/ValidationDebounceDuration.swift new file mode 100644 index 0000000..f6edd0e --- /dev/null +++ b/Sources/SpeziValidation/Configuration/ValidationDebounceDuration.swift @@ -0,0 +1,32 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// The debounce duration for Validation Engine +struct ValidationDebounceDurationKey: EnvironmentKey { + static let defaultValue: Duration = .seconds(0.5) +} + + +extension EnvironmentValues { + /// The configurable debounce duration for input submission. + /// + /// Having a debounce like this, ensures that validation error messages don't get into the way when a user + /// is actively typing into a text field. + /// This duration is used to debounce repeated calls to ``ValidationEngine/submit(input:debounce:)`` where `debounce` is set to `true`. + public var validationDebounce: Duration { + get { + self[ValidationDebounceDurationKey.self] + } + set { + self[ValidationDebounceDurationKey.self] = newValue + } + } +} diff --git a/Sources/SpeziValidation/Configuration/ValidationEngine+Configuration.swift b/Sources/SpeziValidation/Configuration/ValidationEngine+Configuration.swift new file mode 100644 index 0000000..0e36274 --- /dev/null +++ b/Sources/SpeziValidation/Configuration/ValidationEngine+Configuration.swift @@ -0,0 +1,54 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +extension ValidationEngine { + /// The configuration of a ``ValidationEngine``. + public struct Configuration: OptionSet, EnvironmentKey, Equatable { + /// This configuration controls the behavior of the ``ValidationEngine/displayedValidationResults`` property. + /// + /// If ``ValidationEngine/submit(input:debounce:)`` is called with empty input and this option is set, then the + /// ``ValidationEngine/displayedValidationResults`` will display no failed validations. However, + /// ``ValidationEngine/displayedValidationResults`` will still display all validations if validation is done through a manual call to ``ValidationEngine/runValidation(input:)``. + public static let hideFailedValidationOnEmptySubmit = Configuration(rawValue: 1 << 0) + + /// This configuration controls the behavior of the ``ValidationEngine/inputValid`` property. + /// + /// If this configuration is set, the Validation Engine will treat no input (a validation engine + /// that was never run) as being valid. Otherwise, invalid. + public static let considerNoInputAsValid = Configuration(rawValue: 1 << 1) + + /// Default value without any configuration options. + public static let defaultValue: Configuration = [] + + + public let rawValue: UInt + + + public init(rawValue: UInt) { + self.rawValue = rawValue + } + } +} + + +extension EnvironmentValues { + /// Access the ``ValidationEngine/Configuration-swift.struct`` of a ValidationEngine through the environment. + /// + /// - Note: Supplying a value into the environment is always an additive change! + public var validationConfiguration: ValidationEngine.Configuration { + get { + self[ValidationEngine.Configuration.self] + } + set { + self[ValidationEngine.Configuration.self].formUnion(newValue) + } + } +} diff --git a/Sources/SpeziValidation/Resources/Localizable.xcstrings b/Sources/SpeziValidation/Resources/Localizable.xcstrings new file mode 100644 index 0000000..1d6fe59 --- /dev/null +++ b/Sources/SpeziValidation/Resources/Localizable.xcstrings @@ -0,0 +1,116 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "VALIDATION_RULE_MINIMAL_EMAIL" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diese E-Mail ist ungültig." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The provided email is invalid." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "El correo electrónico proporcionado no es válido." + } + } + } + }, + "VALIDATION_RULE_NON_EMPTY" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diese Feld kann nicht leer sein." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This field cannot be empty." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Este espacio no puede estar vacío." + } + } + } + }, + "VALIDATION_RULE_PASSWORD_LENGTH %lld" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dein Passwort muss mindestens %lld Zeichen lang sein." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your password must be at least %lld characters long." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu contraseña debe tener al menos %lld caracteres." + } + } + } + }, + "VALIDATION_RULE_UNICODE_LETTERS" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du kannst nur Zeichen verwenden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You must only use letters." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debes usar solo letras." + } + } + } + }, + "VALIDATION_RULE_UNICODE_LETTERS_ASCII" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Du kannst nur Zeichen aus dem englischen Alphabet verwenden." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You must only use standard English letters." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debes usar solo letras estándar en inglés." + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/Sources/SpeziValidation/Resources/Localizable.xcstrings.license b/Sources/SpeziValidation/Resources/Localizable.xcstrings.license new file mode 100644 index 0000000..5457bcf --- /dev/null +++ b/Sources/SpeziValidation/Resources/Localizable.xcstrings.license @@ -0,0 +1,6 @@ + +This source file is part of the Stanford Spezi open-source project + +SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT diff --git a/Sources/SpeziValidation/SpeziValidation.docc/SpeziValidation.md b/Sources/SpeziValidation/SpeziValidation.docc/SpeziValidation.md new file mode 100644 index 0000000..3033665 --- /dev/null +++ b/Sources/SpeziValidation/SpeziValidation.docc/SpeziValidation.md @@ -0,0 +1,93 @@ +# ``SpeziValidation`` + +Perform input validation and visualize it to the user. + + + +## Overview + +`SpeziValidation` can be used to perform input validation on `String`-based inputs and provides easy-to-use +mechanism to communicate validation feedback back to the user. +The library is based on a rule-based approach using ``ValidationRule``s. + +### Performing Validation + +The only thing you have to do, is to set up the ``SwiftUI/View/validate(input:rules:)-5dac4`` modifier for your +text input. +Supply your input and validation rules. + +The below code example shows a basic validation setup. +Note that we are using the ``VerifiableTextField`` to automatically visualize validation errors to the user. + +```swift +@State var phrase: String = "" + +var body: some View { + Form { + VerifiableTextField("your favorite phrase", text: $phrase) + .validate(input: phrase, rules: .nonEmpty) + } +} +``` + +> Note: The inner views can access the ``ValidationEngine`` using the [Environment](https://developer.apple.com/documentation/swiftui/environment/init(_:)-8slkf) +property wrapper. + +### Managing Validation + +Parent views can access the validation state of their child views using the ``ValidationState`` property wrapper +and the ``SwiftUI/View/receiveValidation(in:)`` modifier. + +The code example below shows +how you can use the validation state of your subview to perform final validation on a button press. + +```swift +@ValidationState var validation + +var body: some View { + Form { + // all subviews that collect data ... + + Button("Submit") { + guard validation.validateSubviews() else { + return + } + + // save data ... + } + } + .receiveValidation(in: $validation) +} +``` + +## Topics + +### Performing Validation + +- ``ValidationRule`` +- ``SwiftUI/View/validate(input:rules:)-5dac4`` +- ``SwiftUI/View/validate(input:rules:)-9vks0`` + +### Managing Validation + +- ``ValidationState`` +- ``SwiftUI/View/receiveValidation(in:)`` + +### Configuration + +- ``SwiftUI/EnvironmentValues/validationConfiguration`` +- ``SwiftUI/EnvironmentValues/validationDebounce`` + +### Visualizing Validation + +- ``VerifiableTextField`` +- ``ValidationResultsView`` +- ``FailedValidationResult`` diff --git a/Sources/SpeziValidation/ValidationEngine.swift b/Sources/SpeziValidation/ValidationEngine.swift new file mode 100644 index 0000000..ff4d5f8 --- /dev/null +++ b/Sources/SpeziValidation/ValidationEngine.swift @@ -0,0 +1,200 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import os +import SwiftUI + + +/// A model that is responsible to verify a list of ``ValidationRule``s. +/// +/// You may use a `ValidationEngine` inside your view hierarchy (using [@StateObject](https://developer.apple.com/documentation/swiftui/stateobject) +/// to manage the evaluation of your ``ValidationRule``s. The Engine provides easy access to bindings for current validity state of a the +/// processed input and a the respective recovery suggestions for failed ``ValidationRule``s. +/// The state of the `ValidationEngine` is updated on each invocation of ``runValidation(input:)`` or ``submit(input:debounce:)``. +@Observable +public class ValidationEngine: Identifiable { + /// Determines the source of the last validation run. + private enum Source: Equatable { + /// The last validation was run due to change in text field or keyboard submit. + case submit + /// The last validation was run due to manual interaction (e.g., a button press). + case manual + } + + + private static let logger = Logger(subsystem: "edu.stanford.spezi.validation", category: "ValidationEngine") + + + /// Unique identifier for this validation engine. + public var id: ObjectIdentifier { + ObjectIdentifier(self) + } + + /// Access to the underlying validation rules. + public let validationRules: [ValidationRule] + + @MainActor private var computedInputValid: Bool? // swiftlint:disable:this discouraged_optional_boolean + + /// A property that indicates if the last processed input is considered valid given the supplied ``ValidationRule`` list. + /// + /// The behavior when no input was provided yet (a validation that was never executed) is being + /// can be influenced using the ``ValidationEngine/Configuration-swift.struct/considerNoInputAsValid`` configuration. + /// By default no input is treated as being invalid. + @MainActor public var inputValid: Bool { + if let computedInputValid { + return computedInputValid + } + + return configuration.contains(.considerNoInputAsValid) + } + + /// A list of ``FailedValidationResult`` for the processed input, providing, e.g., recovery suggestions. + @MainActor public private(set) var validationResults: [FailedValidationResult] = [] + + /// Stores the source of the last validation execution. `nil` if validation was never run. + private var source: Source? + /// Input was empty. By default we consider no input as empty input. + private var inputWasEmpty = true + + /// Flag that indicates if ``displayedValidationResults`` returns any ``FailedValidationResult``. + @MainActor public var isDisplayingValidationErrors: Bool { + let gotResults = !validationResults.isEmpty + + if configuration.contains(.hideFailedValidationOnEmptySubmit) { + return gotResults && (source == .manual || !inputWasEmpty) + } + + return gotResults + } + + + /// A list of ``FailedValidationResult`` for the processed input that should be used by UI components. + /// + /// In certain scenarios it might the desirable to not display any validation results if the user erased the whole + /// input field. You can achieve this by setting the ``ValidationEngine/Configuration-swift.struct/hideFailedValidationOnEmptySubmit`` option + /// and using the ``submit(input:debounce:)`` method. + /// + /// - Note: When calling ``runValidation(input:)`` (e.g., on the button action) this field always delivers + /// the same results as the ``validationResults`` property. + @MainActor public var displayedValidationResults: [FailedValidationResult] { + isDisplayingValidationErrors ? validationResults : [] + } + + /// Access the configuration of the validation engine. + /// + /// You may use the ``SwiftUI/EnvironmentValues/validationConfiguration`` environment key to configure this value from + /// the environment. + public var configuration: Configuration + /// The configurable debounce duration for input submission. + /// + /// This duration is used to debounce repeated calls to ``submit(input:debounce:)`` where `debounce` is set to `true`. + /// You may use the ``SwiftUI/EnvironmentValues/validationDebounce`` environment key to configure this value from + /// the environment. + public var debounceDuration: Duration + + private var debounceTask: Task? { + willSet { + debounceTask?.cancel() + } + } + + + /// Initialize a new `ValidationEngine` by providing a list of ``ValidationRule``s. + /// + /// - Parameters: + /// - validationRules: An array of validation rules. + /// - debounceDuration: The debounce duration used with ``submit(input:debounce:)`` and `debounce` set to `true`. + /// - configuration: The ``Configuration`` of the validation engine. + init( + rules validationRules: [ValidationRule], + debounceFor debounceDuration: Duration = ValidationDebounceDurationKey.defaultValue, + configuration: Configuration = [] + ) { + self.debounceDuration = debounceDuration + self.validationRules = validationRules + self.configuration = configuration + } + + /// Initialize a new `ValidationEngine` by providing a list of ``ValidationRule``s. + /// + /// - Parameters: + /// - validationRules: A variadic array of validation rules. + /// - debounceDuration: The debounce duration used with ``submit(input:debounce:)`` and `debounce` set to `true`. + /// - configuration: The ``Configuration`` of the validation engine. + convenience init( + rules validationRules: ValidationRule..., + debounceFor debounceDuration: Duration = ValidationDebounceDurationKey.defaultValue, + configuration: Configuration = [] + ) { + self.init(rules: validationRules, debounceFor: debounceDuration, configuration: configuration) + } + + @MainActor + private func computeFailedValidations(input: String) { + var results: [FailedValidationResult] = [] + for rule in validationRules { + if let failedValidation = rule.validate(input) { + results.append(failedValidation) + Self.logger.debug("Validation for input '\(input.description)' failed with reason: \(failedValidation.localizedStringResource.localizedString())") + + if rule.effect == .intercept { + break + } + } + } + validationResults = results + } + + @MainActor + private func runValidation0(input: String, source: Source) { + self.source = source // assign it first, as this isn't published + self.inputWasEmpty = input.isEmpty + + computeFailedValidations(input: input) + computedInputValid = validationResults.isEmpty + } + + /// Runs all validations for a given input on text field submission or value change. + /// + /// The input is considered valid if all ``ValidationRule``s succeed or the input is empty. This is particularly + /// useful to reset go back to a valid state if the user submits a empty string in the text field. + /// Make sure to run ``runValidation(input:)`` one last time to process the data (e.g., on a button action). + /// + /// - Parameters: + /// - input: The input to validate. + /// - debounce: If set to `true` calls to this method will be "debounced". The validation will not run as long as + /// there not further calls to this method for the configured `debounceDuration`. If set to `false` the method + /// will run immediately. + @MainActor + public func submit(input: String, debounce: Bool = false) { + guard debounce else { + runValidation0(input: input, source: .submit) + return + } + + debounceTask = Task { + try? await Task.sleep(for: debounceDuration) + + guard !Task.isCancelled else { + return + } + + runValidation0(input: input, source: .submit) + self.debounceTask = nil + } + } + + /// Runs all validations for a given input. + /// + /// The input is considered valid if all ``ValidationRule``s succeed. + /// - Parameter input: The input to validate. + @MainActor + public func runValidation(input: String) { + runValidation0(input: input, source: .manual) + } +} diff --git a/Sources/SpeziValidation/ValidationModifier.swift b/Sources/SpeziValidation/ValidationModifier.swift new file mode 100644 index 0000000..f89dc58 --- /dev/null +++ b/Sources/SpeziValidation/ValidationModifier.swift @@ -0,0 +1,112 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +struct ValidationModifier: ViewModifier { + private let input: String + + @Environment(\.validationConfiguration) private var configuration + @Environment(\.validationDebounce) private var debounce + + @State private var validation: ValidationEngine + @FocusState private var hasFocus: Bool + + init(input: String, rules: [ValidationRule]) { + self.input = input + self._validation = State(wrappedValue: ValidationEngine(rules: rules)) + } + + func body(content: Content) -> some View { + content + .environment(validation) + .focused($hasFocus) + .preference( + key: CapturedValidationStateKey.self, + value: [CapturedValidationState(engine: validation, input: input, focus: $hasFocus)] + ) + .onChange(of: configuration, initial: true) { + validation.configuration = configuration + } + .onChange(of: debounce, initial: true) { + validation.debounceDuration = debounce + } + .onChange(of: input) { + validation.submit(input: input, debounce: true) + } + .onSubmit(of: .text) { + // here we just make sure that we submit it without a debounce + validation.submit(input: input) + } + } +} + +extension View { + /// Validate an input against a set of validation rules. + /// + /// This modifier can be used to validate a `String` input against a set of ``ValidationRule``s. + /// + /// Validation is managed through a ``ValidationEngine`` instance that is injected as an `Observable` into the + /// environment. + /// + /// Below is a short code example on how to use the modifier. We rely on ``VerifiableTextField`` to visualize potential validation errors. + /// ```swift + /// @State var phrase: String = "" + /// + /// var body: some View { + /// Form { + /// VerifiableTextField("your favorite phrase", text: $phrase) + /// .validate(input: phrase, rules: .nonEmpty) + /// } + /// } + /// ``` + /// + /// - Important: You shouldn't place multiple validate modifiers into the same view hierarchy branch. This creates + /// visibility problems in both direction. Both displaying validation results in the child view and receiving + /// validation state from the parent view. + /// + /// - Parameters: + /// - value: The current value to validate. + /// - rules: An array of ``ValidationRule``s. + /// - Returns: The modified view. + public func validate(input value: String, rules: [ValidationRule]) -> some View { + modifier(ValidationModifier(input: value, rules: rules)) + } + + /// Validate an input against a set of validation rules. + /// + /// This modifier can be used to validate a `String` input against a set of ``ValidationRule``s. + /// + /// Validation is managed through a ``ValidationEngine`` instance that is injected as an `Observable` into the + /// environment. + /// + /// Below is a short code example on how to use the modifier. We rely on ``VerifiableTextField`` to visualize potential validation errors. + /// ```swift + /// @State var phrase: String = "" + /// + /// var body: some View { + /// Form { + /// VerifiableTextField("your favorite phrase", text: $phrase) + /// .validate(input: phrase, rules: .nonEmpty) + /// } + /// } + /// ``` + /// + /// - Important: You shouldn't place multiple validate modifiers into the same view hierarchy branch. This creates + /// visibility problems in both direction. Both displaying validation results in the child view and receiving + /// validation state from the parent view. + /// + /// - Parameters: + /// - value: The current value to validate. + /// - rules: An variadic array of ``ValidationRule``s. + /// - Returns: The modified view. + public func validate(input value: String, rules: ValidationRule...) -> some View { + validate(input: value, rules: rules) + } +} diff --git a/Sources/SpeziValidation/ValidationRule+Defaults.swift b/Sources/SpeziValidation/ValidationRule+Defaults.swift new file mode 100644 index 0000000..9df5be1 --- /dev/null +++ b/Sources/SpeziValidation/ValidationRule+Defaults.swift @@ -0,0 +1,104 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +import Foundation + + +extension ValidationRule { + /// A `ValidationRule` that checks that the supplied content is non-empty (`\S+`). + /// + /// The definition of **non-empty** in this context refers to: a string that is not the empty string and + /// does also not just contain whitespace-only characters. + public static let nonEmpty: ValidationRule = { + guard let regex = try? Regex(#".*\S+.*"#) else { + fatalError("Failed to build the nonEmpty validation rule!") + } + + return ValidationRule(regex: regex, message: "VALIDATION_RULE_NON_EMPTY", bundle: .module) + }() + + /// A `ValidationRule` that checks that the supplied content only contains unicode letters. + /// + /// - See: `Character/isLetter`. + public static let unicodeLettersOnly: ValidationRule = { + ValidationRule(rule: { $0.allSatisfy { $0.isLetter } }, message: "VALIDATION_RULE_UNICODE_LETTERS", bundle: .module) + }() + + /// A `ValidationRule` that checks that the supplied contain only contains ASCII letters. + /// + /// - Note: It is recommended to use ``unicodeLettersOnly`` in production environments. + /// - See: `Character/isASCII`. + public static let asciiLettersOnly: ValidationRule = { + ValidationRule(rule: { $0.allSatisfy { $0.isASCII } }, message: "VALIDATION_RULE_UNICODE_LETTERS_ASCII", bundle: .module) + }() + + /// A `ValidationRule` that imposes minimal constraints on a E-Mail input. + /// + /// This ValidationRule matches with any strings that contain at least one `@` symbol followed by at least one + /// character (`.*@.+`). Use this in environments where you verify the existence and ownership of the E-Mail + /// address (e.g., by sending a verification link to the address). + /// + /// - See: A more detailed discussion about validation E-Mail inout can be found [here](https://stackoverflow.com/a/48170419). + public static let minimalEmail: ValidationRule = { + guard let regex = try? Regex(".*@.+") else { + fatalError("Failed to build the minimalEmail validation rule!") + } + + return ValidationRule( + regex: regex, + message: "VALIDATION_RULE_MINIMAL_EMAIL", + bundle: .module + ) + }() + + /// A `ValidationRule` that requires a password of at least 8 characters for minimal password complexity. + /// + /// See ``ValidationRule`` for a discussion and recommendation on password complexity rules. + public static let minimalPassword: ValidationRule = { + guard let regex = try? Regex(#".{8,}"#) else { + fatalError("Failed to build the minimalPassword validation rule!") + } + + return ValidationRule( + regex: regex, + message: "VALIDATION_RULE_PASSWORD_LENGTH \(8)", + bundle: .module + ) + }() + + /// A `ValidationRule` that requires a password of at least 10 characters for improved password complexity. + /// + /// See ``ValidationRule`` for a discussion and recommendation on password complexity rules. + public static let mediumPassword: ValidationRule = { + guard let regex = try? Regex(#".{10,}"#) else { + fatalError("Failed to build the mediumPassword validation rule!") + } + + return ValidationRule( + regex: regex, + message: "VALIDATION_RULE_PASSWORD_LENGTH \(10)", + bundle: .module + ) + }() + + /// A `ValidationRule` that requires a password of at least 10 characters for extended password complexity. + /// + /// See ``ValidationRule`` for a discussion and recommendation on password complexity rules. + public static let strongPassword: ValidationRule = { + guard let regex = try? Regex(#".{12,}"#) else { + fatalError("Failed to build the strongPassword validation rule!") + } + + return ValidationRule( + regex: regex, + message: "VALIDATION_RULE_PASSWORD_LENGTH \(12)", + bundle: .module + ) + }() +} diff --git a/Sources/SpeziValidation/ValidationRule.swift b/Sources/SpeziValidation/ValidationRule.swift new file mode 100644 index 0000000..4a1f7bc --- /dev/null +++ b/Sources/SpeziValidation/ValidationRule.swift @@ -0,0 +1,189 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + + +import Foundation +import SpeziViews + + +/// Controls how a ``ValidationEngine`` deals with subsequent validation rules if a given validation rule reports invalid input. +enum CascadingValidationEffect { + /// The ``ValidationEngine`` continues to validate input against subsequent ``ValidationRule``s. + case `continue` + /// The ``ValidationEngine`` intercepts the current processing chain if the current rule reports invalid input and + /// does not validate input against subsequent ``ValidationRule``s. + case intercept +} + + +/// A rule used for validating text along with a message to display if the validation fails. +/// +/// The following example demonstrates a ``ValidationRule`` using a regex expression for an email. +/// ```swift +/// ValidationRule( +/// regex: try? Regex("[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"), +/// message: "The entered email is not correct." +/// ) +/// ``` +/// +/// Use the ``SwiftUI/View/validate(input:rules:)-5dac4`` modifier to apply a validation rule to a given `String` input. +/// +/// ### Discussion on security-related client-side Validation +/// +/// This discussion section briefly touches on important aspects when doing security-related, client-side validation and highlights +/// the importance of server-side validation to properly enforce restrictions. +/// +/// - Important: Never rely on security-relevant validations with `ValidationRule`. These are client-side validations only! +/// Security-related validations MUST be checked at the server side (e.g., password length) and are just checked +/// on client-side for visualization. +/// +/// #### Password Validation +/// +/// An application must make sure that users choose sufficiently secure passwords +/// while ensuring at the same time that usability is not affected due to too complex restrictions. +/// This basic motivation stems from the section `ORP.4.A22 Regulating Password Quality` +/// of the [IT-Grundschutz Compendium](https://www.bsi.bund.de/EN/Themen/Unternehmen-und-Organisationen/Standards-und-Zertifizierung/IT-Grundschutz/it-grundschutz_node.html) +/// of the German Federal Office for Information Security. +/// We propose to use the password length as the sole factor to determine password complexity. We rely on the +/// recommendations of NIST who discuss the [Strength of Memorized Secrets](https://pages.nist.gov/800-63-3/sp800-63b.html#appA) +/// in great detail and recommend against password rules that mandated a certain mix of character types. +/// +/// ## Topics +/// +/// ### Builtin Rules +/// - ``nonEmpty`` +/// - ``unicodeLettersOnly`` +/// - ``asciiLettersOnly`` +/// - ``minimalEmail`` +/// - ``minimalPassword`` +/// - ``mediumPassword`` +/// - ``strongPassword`` +public struct ValidationRule: Identifiable, @unchecked Sendable, Equatable { + // we guarantee that the closure is only executed on the main thread + /// A unique identifier for the ``ValidationRule``. Can be used to, e.g., match a ``FailedValidationResult`` to the ValidationRule. + public let id: UUID + private let rule: (String) -> Bool + /// A localized message that describes a recovery suggestion if the validation rule fails. + public let message: LocalizedStringResource + let effect: CascadingValidationEffect + + + // swiftlint:disable:next function_default_parameter_at_end + init( + id: UUID = UUID(), + ruleClosure: @escaping (String) -> Bool, + message: LocalizedStringResource, + effect: CascadingValidationEffect = .continue + ) { + self.id = id + self.rule = ruleClosure + self.message = message + self.effect = effect + } + + + /// Creates a validation rule from an escaping closure. + /// + /// - Parameters: + /// - rule: An escaping closure that validates a `String` and returns a boolean result. + /// - message: A `String` message to display if validation fails. + public init(rule: @escaping (String) -> Bool, message: LocalizedStringResource) { + self.init(ruleClosure: rule, message: message) + } + + /// Creates a validation rule from an escaping closure. + /// + /// - Parameters: + /// - rule: An escaping closure that validates a `String` and returns a boolean result. + /// - message: A `String` message to display if validation fails. + /// - bundle: The Bundle to localize for. + public init(rule: @escaping (String) -> Bool, message: String.LocalizationValue, bundle: Bundle) { + self.init(ruleClosure: rule, message: LocalizedStringResource(message, bundle: .atURL(from: bundle))) + } + + /// Creates a validation rule from a regular expression. + /// + /// - Parameters: + /// - regex: A `Regex` regular expression to match for validating text. Note, the `wholeMatch` operation is used. + /// - message: A `LocalizedStringResource` message to display if validation fails. + public init(regex: Regex, message: LocalizedStringResource) { + self.init(ruleClosure: { (try? regex.wholeMatch(in: $0) != nil) ?? false }, message: message) + } + + /// Creates a validation rule from a regular expression. + /// + /// - Parameters: + /// - regex: A `Regex` regular expression to match for validating text. Note, the `wholeMatch` operation is used. + /// - message: A `String` message to display if validation fails. + /// - bundle: The Bundle to localize for. + public init(regex: Regex, message: String.LocalizationValue, bundle: Bundle) { + self.init(regex: regex, message: LocalizedStringResource(message, bundle: .atURL(from: bundle))) + } + + /// Creates a validation rule by copying the rule contents from another `ValidationRule`. + /// - Parameters: + /// - validationRule: The `ValidationRule` to copy the rule from. + /// - message: A new message for the copied validation rule. + public init(copy validationRule: ValidationRule, message: LocalizedStringResource) { + self.init(ruleClosure: validationRule.rule, message: message) + } + + + public static func == (lhs: ValidationRule, rhs: ValidationRule) -> Bool { + lhs.id == rhs.id + } + + + /// Validates the contents of a given `String` input. + /// - Parameter input: The input to validate. + /// - Returns: Returns a ``FailedValidationResult`` if validation failed, otherwise `nil`. + @MainActor + public func validate(_ input: String) -> FailedValidationResult? { + guard !rule(input) else { + return nil + } + + return FailedValidationResult(from: self) + } +} + + +extension ValidationRule { + /// Annotates an given ``ValidationRule`` such that a processing ``ValidationEngine`` intercepts the current + /// processing chain of validation rules, if the current validation rule determines a given input to be invalid. + /// - Parameter rule: The ``ValidationRule`` to modify. + /// - Returns: Returns a modified ``ValidationRule`` + public var intercepting: ValidationRule { + ValidationRule(id: id, ruleClosure: rule, message: message, effect: .intercept) + } +} + + +extension ValidationRule: Decodable { + enum CodingKeys: String, CodingKey { + case rule + case message + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + + let regexString = try values.decode(String.self, forKey: .rule) + let regex = try Regex(regexString) + + let message: LocalizedStringResource + do { + // backwards compatibility. An earlier version of `ValidationRule` used a non-localized string field. + message = LocalizedStringResource(stringLiteral: try values.decode(String.self, forKey: .message)) + } catch { + message = try values.decode(LocalizedStringResource.self, forKey: .message) + } + + self.init(regex: regex, message: message) + } +} diff --git a/Sources/SpeziValidation/ValidationState/CapturedValidationState.swift b/Sources/SpeziValidation/ValidationState/CapturedValidationState.swift new file mode 100644 index 0000000..c3a52c0 --- /dev/null +++ b/Sources/SpeziValidation/ValidationState/CapturedValidationState.swift @@ -0,0 +1,53 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// A momentary snapshot of the current validation state of a view. +/// +/// This structure provides context to a particular ``ValidationEngine`` instance by capturing it's input +/// and optionally a [FocusState](https://developer.apple.com/documentation/SwiftUI/FocusState) value. +/// +/// This particularly allows to run a validation from the outside of a view. +@dynamicMemberLookup +public struct CapturedValidationState { + private let engine: ValidationEngine + private let input: String + private let focusState: FocusState.Binding + + init(engine: ValidationEngine, input: String, focus focusState: FocusState.Binding) { + self.engine = engine + self.input = input + self.focusState = focusState + } + + /// Moves focus to this field. + func moveFocus() { + focusState.wrappedValue = true + } + + /// Execute the validation engine for the current state of the captured view. + @MainActor public func runValidation() { + engine.runValidation(input: input) + } + + /// Access properties of the underlying ``ValidationEngine``. + /// - Parameter keyPath: The key path into the validation engine. + /// - Returns: The value of the property. + public subscript(dynamicMember keyPath: KeyPath) -> Value { + engine[keyPath: keyPath] + } +} + + +extension CapturedValidationState: Equatable { + public static func == (lhs: CapturedValidationState, rhs: CapturedValidationState) -> Bool { + lhs.engine === rhs.engine && lhs.input == rhs.input + } +} diff --git a/Sources/SpeziValidation/ValidationState/FailedValidationResult.swift b/Sources/SpeziValidation/ValidationState/FailedValidationResult.swift new file mode 100644 index 0000000..ccbe73c --- /dev/null +++ b/Sources/SpeziValidation/ValidationState/FailedValidationResult.swift @@ -0,0 +1,29 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import Foundation + + +/// A failed validation result of a ``ValidationRule`` for a particular input. +/// +/// For more information see ``ValidationRule/validate(_:)``. +public struct FailedValidationResult: Identifiable, Equatable, CustomLocalizedStringResourceConvertible { + /// The identifier of the ``ValidationRule`` that produced this result. + public var id: ValidationRule.ID + /// A recovery suggestion for the validated input. + public let message: LocalizedStringResource + + public var localizedStringResource: LocalizedStringResource { + message + } + + init(from rule: ValidationRule) { + self.id = rule.id + self.message = rule.message + } +} diff --git a/Sources/SpeziValidation/ValidationState/ReceiveValidationModifier.swift b/Sources/SpeziValidation/ValidationState/ReceiveValidationModifier.swift new file mode 100644 index 0000000..0c36ee4 --- /dev/null +++ b/Sources/SpeziValidation/ValidationState/ReceiveValidationModifier.swift @@ -0,0 +1,43 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// Provide access to validation state to the parent view. +/// +/// The internal preference key to provide parent views access to all configured ``ValidationEngine`` and input +/// state by capturing it into a ``CapturedValidationState``. +struct CapturedValidationStateKey: PreferenceKey { + static var defaultValue: [CapturedValidationState] { + [] + } + + static func reduce(value: inout [CapturedValidationState], nextValue: () -> [CapturedValidationState]) { + value.append(contentsOf: nextValue()) + } +} + + +extension View { + /// Receive validation state of all subviews. + /// + /// By supplying a binding to your declared ``ValidationState`` property, you can receive all changes to the + /// validation state of your child views. + /// + /// When calling the ``ValidationContext/validateSubviews(switchFocus:)`` focus automatically switches to the + /// first field that failed validation. + /// + /// - Parameter state: The binding to the ``ValidationState``. + /// - Returns: The modified view. + public func receiveValidation(in state: ValidationState.Binding) -> some View { + onPreferenceChange(CapturedValidationStateKey.self) { entries in + state.wrappedValue = ValidationContext(entries: entries) + } + } +} diff --git a/Sources/SpeziValidation/ValidationState/ValidationContext.swift b/Sources/SpeziValidation/ValidationState/ValidationContext.swift new file mode 100644 index 0000000..f90ac7e --- /dev/null +++ b/Sources/SpeziValidation/ValidationState/ValidationContext.swift @@ -0,0 +1,129 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// The validation context managed by a validation state modifier. +/// +/// The `ValidationContext` is the state managed by the ``ValidationState`` property wrapper. +/// It provides access to the ``ValidationEngine``s of all subviews by capturing them with +/// ``CapturedValidationState``. +/// +/// You can use this structure to retrieve the state of all ``ValidationEngine``s of a subview or manually +/// initiate validation by calling ``validateSubviews(switchFocus:)``. E.g., when pressing on a submit button of a form. +public struct ValidationContext { + private let entries: [CapturedValidationState] + + + /// Indicates if all input is currently considered valid. + /// + /// Please refer to the documentation of ``ValidationEngine/inputValid``. + @MainActor + public var allInputValid: Bool { + entries.allSatisfy { $0.inputValid } + } + + /// Collects all failed validations from all subviews. + /// + /// Please refer to the documentation of ``ValidationEngine/validationResults``. + @MainActor + public var allValidationResults: [FailedValidationResult] { + entries.reduce(into: []) { result, state in + result.append(contentsOf: state.validationResults) + } + } + + /// Collects all failed validations from all subviews that should be displayed by UI components. + /// + /// Please refer to the documentation of ``ValidationEngine/displayedValidationResults``. + @MainActor + public var allDisplayedValidationResults: [FailedValidationResult] { + entries.reduce(into: []) { result, state in + result.append(contentsOf: state.displayedValidationResults) + } + } + + /// Flag that indicates if ``allDisplayedValidationResults`` returns any results. + /// + /// Please refer to the documentation of ``ValidationEngine/isDisplayingValidationErrors``. + @MainActor + public var isDisplayingValidationErrors: Bool { + entries.contains { $0.isDisplayingValidationErrors } + } + + + init() { + self.init(entries: []) + } + + + init(entries: [CapturedValidationState]) { + self.entries = entries + } + + + @MainActor private func collectFailedValidations() -> [CapturedValidationState] { + compactMap { state in + state.runValidation() + + guard !state.inputValid else { + return nil + } + + return state + } + } + + /// Run the validation engines of all your subviews. + /// + /// If provided, the `FocusState` will be automatically set to the first field + /// that reported a failed validation result. + /// + /// - Parameter switchFocus: Flag that controls the automatic focus switching mechanisms. Default turned on. + /// - Returns: Returns `true` if all subviews reported valid data. Returns `false` if at least one + /// subview reported invalid data. + @MainActor + @discardableResult + public func validateSubviews(switchFocus: Bool = true) -> Bool { + let failedFields = collectFailedValidations() + + if let field = failedFields.first { + if switchFocus { + // move focus to the first field that failed validation + field.moveFocus() + } + + return false + } + + return true + } +} + + +extension ValidationContext: Equatable {} + + +extension ValidationContext: Collection { + public var startIndex: Int { + entries.startIndex + } + + public var endIndex: Int { + entries.endIndex + } + + public func index(after index: Int) -> Int { + entries.index(after: index) + } + + public subscript(position: Int) -> CapturedValidationState { + entries[position] + } +} diff --git a/Sources/SpeziValidation/ValidationState/ValidationState.swift b/Sources/SpeziValidation/ValidationState/ValidationState.swift new file mode 100644 index 0000000..f4b8f53 --- /dev/null +++ b/Sources/SpeziValidation/ValidationState/ValidationState.swift @@ -0,0 +1,93 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// Property wrapper to retrieve validation state of subviews. +/// +/// The `ValidationState` property wrapper can be used to retrieve the validation state of +/// subviews and manually initiate validation (e.g., when pressing the submit button of a form). +/// To do so, you would typically call ``ValidationContext/validateSubviews(switchFocus:)`` within the `Button` +/// action. This call can be used to automatically switch focus to the first field that failed validation. +/// +/// The `ValidationState` property wrapper works in conjunction with the ``SwiftUI/View/receiveValidation(in:)`` modifier +/// to receive validation state from the child views. +/// +/// Below is a short code example of a typical setup: +/// ```swift +/// @ValidationState var validation +/// +/// var body: some View { +/// Form { +/// // all subviews that collect data ... +/// +/// Button("Submit") { +/// guard validation.validateSubviews() else { +/// return +/// } +/// +/// // save data ... +/// } +/// } +/// .receiveValidation(in: $validation) +/// } +/// ``` +/// +/// ## Topics +/// +/// ### Inspecting Validation State +/// - ``ValidationContext`` +/// - ``CapturedValidationState`` +/// - ``ValidationEngine`` +@propertyWrapper +public struct ValidationState: DynamicProperty { + @State private var state = ValidationContext() + + /// Access the captured validation context. + public var wrappedValue: ValidationContext { + state + } + + /// Creates a binding that you can pass around. + public var projectedValue: ValidationState.Binding { + Binding(binding: $state) + } + + + public init() {} +} + + +extension ValidationState { + /// A binding to a ``ValidationState``. + @propertyWrapper + public struct Binding { + private let binding: SwiftUI.Binding + + /// The validation context. + public var wrappedValue: ValidationContext { + get { + binding.wrappedValue + } + nonmutating set { + binding.wrappedValue = newValue + } + } + + /// Creates a binding. + public var projectedValue: Binding { + self + } + + + init(binding: SwiftUI.Binding) { + self.binding = binding + } + } +} diff --git a/Sources/SpeziValidation/Views/ValidationResultsView.swift b/Sources/SpeziValidation/Views/ValidationResultsView.swift new file mode 100644 index 0000000..1702121 --- /dev/null +++ b/Sources/SpeziValidation/Views/ValidationResultsView.swift @@ -0,0 +1,41 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + +/// A view that displays the results of a ``ValidationEngine`` (see ``FailedValidationResult``). +public struct ValidationResultsView: View { + private let results: [FailedValidationResult] + + public var body: some View { + VStack(alignment: .leading) { + ForEach(results) { result in + Text(result.message) + .fixedSize(horizontal: false, vertical: true) + } + } + .font(.footnote) + .foregroundColor(.red) + } + + /// Create a new view. + /// - Parameter results: The array of ``FailedValidationResult`` coming from the ``ValidationEngine``. + public init(results: [FailedValidationResult]) { + self.results = results + } +} + + +#if DEBUG +#Preview { + ValidationResultsView(results: [ + .init(from: .nonEmpty), + .init(from: .mediumPassword) + ]) +} +#endif diff --git a/Sources/SpeziValidation/Views/VerifiableTextField.swift b/Sources/SpeziValidation/Views/VerifiableTextField.swift new file mode 100644 index 0000000..36fcbe5 --- /dev/null +++ b/Sources/SpeziValidation/Views/VerifiableTextField.swift @@ -0,0 +1,105 @@ +// +// This source file is part of the Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +/// A `TextField` that automatically handles validation of input. +/// +/// This text field expects a ``ValidationEngine`` object in the environment. The engine is used +/// to validate the text field input. A ``ValidationResultsView`` is used to automatically display +/// recovery suggestions for failed ``ValidationRule`` below the text field. +public struct VerifiableTextField: View { + /// The type of text field. + public enum TextFieldType { + /// A standard `TextField`. + case text + /// A `SecureField`. + case secure + } + + private let label: FieldLabel + private let textFieldFooter: FieldFooter + private let fieldType: TextFieldType + + @Binding private var text: String + + @Environment(ValidationEngine.self) + var validationEngine + + public var body: some View { + VStack { + Group { + switch fieldType { + case .text: + TextField(text: $text, label: { label }) + case .secure: + SecureField(text: $text, label: { label }) + } + } + + HStack { + ValidationResultsView(results: validationEngine.displayedValidationResults) + + Spacer() + + textFieldFooter + } + } + } + + + /// Create a new verifiable text field. + /// - Parameters: + /// - label: The localized text label for the text field. + /// - text: The binding to the stored value. + /// - type: An optional ``TextFieldType``. + /// - footer: An optional footer displayed below the text field next to the ``ValidationResultsView``. + public init( + _ label: LocalizedStringResource, + text: Binding, + type: TextFieldType = .text, + @ViewBuilder footer: () -> FieldFooter = { EmptyView() } + ) where FieldLabel == Text { + self.init(text: text, type: type, label: { Text(label) }, footer: footer) + } + + /// Create a new verifiable text field. + /// - Parameters: + /// - text: The binding to the stored value. + /// - type: An optional ``TextFieldType``. + /// - label: An arbitrary label for the text field. + /// - footer: An optional footer displayed below the text field next to the ``ValidationResultsView`` + public init( + text: Binding, + type: TextFieldType = .text, + @ViewBuilder label: () -> FieldLabel, + @ViewBuilder footer: () -> FieldFooter = { EmptyView() } + ) { + self._text = text + self.fieldType = type + self.label = label() + self.textFieldFooter = footer() + } +} + + +#if DEBUG +#Preview { + @State var text = "" + return Form { + VerifiableTextField(text: $text) { + Text(verbatim: "Password Text") + } footer: { + Text(verbatim: "Some Hint") + .font(.footnote) + } + .validate(input: text, rules: .nonEmpty) + } +} +#endif diff --git a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md index 324c00b..371f059 100644 --- a/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md +++ b/Sources/SpeziViews/SpeziViews.docc/SpeziViews.md @@ -50,5 +50,4 @@ SPDX-License-Identifier: MIT ### Managing Focus -- ``SwiftUI/View/onTapFocus()`` -- ``SwiftUI/View/onTapFocus(focusedField:fieldIdentifier:)-1rxxf`` +- ``SwiftUI/View/focusOnTap()`` diff --git a/Sources/SpeziViews/ViewModifier/FocusOnTapModifier.swift b/Sources/SpeziViews/ViewModifier/FocusOnTapModifier.swift new file mode 100644 index 0000000..dfff1b1 --- /dev/null +++ b/Sources/SpeziViews/ViewModifier/FocusOnTapModifier.swift @@ -0,0 +1,40 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SwiftUI + + +private struct FocusOnTapModifier: ViewModifier { + @FocusState var isFocused: Bool + + init() {} + + + func body(content: Content) -> some View { + content + .focused($isFocused) // tracks focus state + .onTapGesture { + isFocused = true + } + } +} + + +extension View { + /// Move focus to this view when it is tapped. + /// + /// The the view is modified such that it receives focus once it is tapped. + /// + /// This modifier is useful, e.g., in combination with the ``DescriptionGridRow`` when + /// used with a `TextField`. This enables the user to focus the text field by tapping the description label. + /// + /// - Returns: The modified view. + public func focusOnTap() -> some View { + modifier(FocusOnTapModifier()) + } +} diff --git a/Sources/SpeziViews/ViewModifier/OnTapFocus.swift b/Sources/SpeziViews/ViewModifier/OnTapFocus.swift deleted file mode 100644 index d401c18..0000000 --- a/Sources/SpeziViews/ViewModifier/OnTapFocus.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// This source file is part of the Stanford Spezi open-source project -// -// SPDX-FileCopyrightText: 2022 Stanford University and the project authors (see CONTRIBUTORS.md) -// -// SPDX-License-Identifier: MIT -// - -import SwiftUI - - -private struct UUIDOnTapFocus: ViewModifier { - @FocusState var focusedState: UUID? - - func body(content: Content) -> some View { - content - .onTapFocus(focusedField: $focusedState, fieldIdentifier: UUID()) - } -} - - -private struct OnTapFocus: ViewModifier { - private let fieldIdentifier: FocusedField - - @FocusState.Binding var focusedState: FocusedField? - - init( - focusedState: FocusState.Binding, - fieldIdentifier: FocusedField - ) { - self._focusedState = focusedState - self.fieldIdentifier = fieldIdentifier - } - - - func body(content: Content) -> some View { - content - .focused($focusedState, equals: fieldIdentifier) - .onTapGesture { - focusedState = fieldIdentifier - } - } -} - - -extension View { - /// Modifies the view to be in a focused state (e.g., `TextFields`) if it is tapped. - public func onTapFocus() -> some View { - modifier(UUIDOnTapFocus()) - } - - /// Modifies the view to be in a focused state (e.g., `TextFields`) if it is tapped. - /// - Parameters: - /// - focusedField: The `FocusState` binding that should be set. - /// - fieldIdentifier: The identifier that the `focusedField` should be set to. - public func onTapFocus( - focusedField: FocusState.Binding, - fieldIdentifier: FocusedField - ) -> some View { - modifier(OnTapFocus(focusedState: focusedField, fieldIdentifier: fieldIdentifier)) - } -} diff --git a/Tests/UITests/TestApp/Localizable.xcstrings b/Tests/UITests/TestApp/Localizable.xcstrings index c4dc27a..f5d5de8 100644 --- a/Tests/UITests/TestApp/Localizable.xcstrings +++ b/Tests/UITests/TestApp/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "%@" : { + + }, "%lf" : { }, @@ -12,15 +15,24 @@ }, "Error Description" : { + }, + "Field" : { + }, "First Name" : { + }, + "Has Engines: %@" : { + }, "Hello Throwing World" : { }, "Hello World" : { + }, + "Input Valid: %@" : { + }, "LABEL_TEXT" : { "localizations" : { @@ -34,6 +46,9 @@ }, "Last Name" : { + }, + "Last state: %@" : { + }, "LAZY_TEXT" : { "localizations" : { @@ -53,9 +68,15 @@ }, "SpeziPersonalInfo" : { + }, + "SpeziValidation" : { + }, "SpeziViews" : { + }, + "Switch Focus" : { + }, "Targets" : { @@ -65,6 +86,9 @@ }, "This is a label ...\nAn other text. This is longer and we can check if the justified text works as expected. This is a very long text." : { + }, + "Validate" : { + }, "View State: %@" : { diff --git a/Tests/UITests/TestApp/PersonalInfoTests/SpeziPersonalInfoTests.swift b/Tests/UITests/TestApp/PersonalInfoTests/SpeziPersonalInfoTests.swift index 02fd31b..f9ea04b 100644 --- a/Tests/UITests/TestApp/PersonalInfoTests/SpeziPersonalInfoTests.swift +++ b/Tests/UITests/TestApp/PersonalInfoTests/SpeziPersonalInfoTests.swift @@ -47,8 +47,7 @@ enum SpeziPersonalInfoTests: String, TestAppTests { } } -#if DEBUG + #Preview { TestAppTestsView() } -#endif diff --git a/Tests/UITests/TestApp/SpeziViewsTargetsTests.swift b/Tests/UITests/TestApp/SpeziViewsTargetsTests.swift index 9bb7ab3..3891c71 100644 --- a/Tests/UITests/TestApp/SpeziViewsTargetsTests.swift +++ b/Tests/UITests/TestApp/SpeziViewsTargetsTests.swift @@ -13,6 +13,7 @@ import XCTestApp struct SpeziViewsTargetsTests: View { @State var presentingSpeziViews = false @State var presentingSpeziPersonalInfo = false + @State var presentingSpeziValidation = false var body: some View { @@ -24,6 +25,9 @@ struct SpeziViewsTargetsTests: View { Button("SpeziPersonalInfo") { presentingSpeziPersonalInfo = true } + Button("SpeziValidation") { + presentingSpeziValidation = true + } } .navigationTitle("Targets") } @@ -33,6 +37,9 @@ struct SpeziViewsTargetsTests: View { .sheet(isPresented: $presentingSpeziPersonalInfo) { TestAppTestsView() } + .sheet(isPresented: $presentingSpeziValidation) { + TestAppTestsView() + } } } diff --git a/Tests/UITests/TestApp/ValidationTests/DefaultValidationRules.swift b/Tests/UITests/TestApp/ValidationTests/DefaultValidationRules.swift new file mode 100644 index 0000000..f9732ea --- /dev/null +++ b/Tests/UITests/TestApp/ValidationTests/DefaultValidationRules.swift @@ -0,0 +1,30 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziValidation +import SwiftUI + +struct DefaultValidationRules: View { + @State var input: String = "" + var body: some View { + VerifiableTextField("Field", text: $input) + .validate(input: input, rules: [ + .nonEmpty, + .unicodeLettersOnly, + .asciiLettersOnly, + .minimalEmail, + .minimalPassword, + .mediumPassword, + .strongPassword + ]) + } +} + +#Preview { + DefaultValidationRules() +} diff --git a/Tests/UITests/TestApp/ValidationTests/FocusedValidationTests.swift b/Tests/UITests/TestApp/ValidationTests/FocusedValidationTests.swift new file mode 100644 index 0000000..0867be8 --- /dev/null +++ b/Tests/UITests/TestApp/ValidationTests/FocusedValidationTests.swift @@ -0,0 +1,60 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziValidation +import SwiftUI + +enum Field: String, Hashable { + case input = "Input" + case nonEmptyInput = "Non-Empty Input" +} + + +struct FocusedValidationTests: View { + // CHILD VIEW CONTENT + @State var input: String = "" + @State var nonEmptyInput: String = "" + + // PARENT VIEW CONTENT + @ValidationState var validation + + @State var lastValid: Bool? // swiftlint:disable:this discouraged_optional_boolean + @State var switchFocus = true + @FocusState var focus: Field? + + var body: some View { + Form { + Section { + Text("Has Engines: \(!validation.isEmpty ? "Yes" : "No")") + Text("Input Valid: \(validation.allInputValid ? "Yes" : "No")") + if let lastValid { + Text("Last state: \(lastValid ? "valid" : "invalid")") + } + Button("Validate", action: { + // validating without direct access to the input value + lastValid = validation.validateSubviews(switchFocus: switchFocus) + }) + Toggle("Switch Focus", isOn: $switchFocus) + } + + VerifiableTextField("\(Field.input.rawValue)", text: $input) + .focused($focus, equals: .input) + .validate(input: input, rules: .minimalPassword) + + VerifiableTextField("\(Field.nonEmptyInput.rawValue)", text: $nonEmptyInput) + .focused($focus, equals: .nonEmptyInput) + .validate(input: nonEmptyInput, rules: .nonEmpty) + } + .receiveValidation(in: $validation) + } +} + + +#Preview { + FocusedValidationTests() +} diff --git a/Tests/UITests/TestApp/ValidationTests/SpeziValidationTests.swift b/Tests/UITests/TestApp/ValidationTests/SpeziValidationTests.swift new file mode 100644 index 0000000..be9bd54 --- /dev/null +++ b/Tests/UITests/TestApp/ValidationTests/SpeziValidationTests.swift @@ -0,0 +1,31 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import SpeziPersonalInfo +import SwiftUI +import XCTestApp + + +enum SpeziValidationTests: String, TestAppTests { + case validation = "Validation" + case validationRules = "ValidationRules" + + func view(withNavigationPath path: Binding) -> some View { + switch self { + case .validation: + FocusedValidationTests() + case .validationRules: + DefaultValidationRules() + } + } +} + + +#Preview { + TestAppTestsView() +} diff --git a/Tests/UITests/TestAppUITests/SpeziValidation/ValidationTests.swift b/Tests/UITests/TestAppUITests/SpeziValidation/ValidationTests.swift new file mode 100644 index 0000000..de4156d --- /dev/null +++ b/Tests/UITests/TestAppUITests/SpeziValidation/ValidationTests.swift @@ -0,0 +1,86 @@ +// +// This source file is part of the Stanford Spezi open-source project +// +// SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) +// +// SPDX-License-Identifier: MIT +// + +import XCTest +import XCTestExtensions + + +final class ValidationTests: XCTestCase { + override func setUpWithError() throws { + try super.setUpWithError() + + continueAfterFailure = false + + let app = XCUIApplication() + app.launch() + + app.open(target: "SpeziValidation") + } + + func testDefaultRules() { + let app = XCUIApplication() + + XCTAssert(app.buttons["ValidationRules"].waitForExistence(timeout: 2)) + app.buttons["ValidationRules"].tap() + } + + func testValidationWithFocus() throws { + let app = XCUIApplication() + + let passwordMessage = "Your password must be at least 8 characters long." + let emptyMessage = "This field cannot be empty." + + XCTAssert(app.buttons["Validation"].waitForExistence(timeout: 2)) + app.buttons["Validation"].tap() + + XCTAssertTrue(app.staticTexts["Has Engines: Yes"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(app.staticTexts["Input Valid: No"].exists) + XCTAssertFalse(app.staticTexts[passwordMessage].exists) + XCTAssertFalse(app.staticTexts[emptyMessage].exists) + + XCTAssertTrue(app.buttons["Validate"].exists) + app.buttons["Validate"].tap() + XCTAssertTrue(app.staticTexts["Last state: invalid"].waitForExistence(timeout: 1.0)) + XCTAssertTrue(app.staticTexts[passwordMessage].exists) + XCTAssertTrue(app.staticTexts[emptyMessage].exists) + + // we verify the contract. Testing that the first field receives input! + app.typeText("Hello World") // do not dismiss keyboard here + + XCTAssertTrue(app.textFields["Hello World"].waitForExistence(timeout: 0.5)) + XCTAssertFalse(app.staticTexts[passwordMessage].exists) + XCTAssertTrue(app.staticTexts[emptyMessage].exists) + + print(app.switches["Switch Focus"].debugDescription) + XCTAssertTrue(app.switches["Switch Focus"].exists) + app.switches.allElementsBoundByIndex[1].tap() // toggles automatic focus switch off + + app.buttons["Validate"].tap() + + app.typeText("!") + app.dismissKeyboard() + + XCTAssertTrue(app.textFields["Hello World!"].waitForExistence(timeout: 0.5)) + app.switches.allElementsBoundByIndex[1].tap() // toggles automatic focus switch on + + app.buttons["Validate"].tap() + + app.typeText("Word") + app.dismissKeyboard() + + XCTAssertTrue(app.staticTexts["Input Valid: Yes"].waitForExistence(timeout: 2.0)) + XCTAssertTrue(app.staticTexts["Last state: invalid"].exists) + + XCTAssertTrue(app.buttons["Validate"].exists) + app.buttons["Validate"].tap() + XCTAssertTrue(app.staticTexts["Last state: valid"].waitForExistence(timeout: 1.0)) + + XCTAssertTrue(app.textFields["Hello World!"].exists) + XCTAssertTrue(app.textFields["Word"].exists) + } +} diff --git a/Tests/UITests/UITests.xcodeproj/project.pbxproj b/Tests/UITests/UITests.xcodeproj/project.pbxproj index 17f80fe..34ea96d 100644 --- a/Tests/UITests/UITests.xcodeproj/project.pbxproj +++ b/Tests/UITests/UITests.xcodeproj/project.pbxproj @@ -20,12 +20,16 @@ 2FB099B82A8AD25300B20952 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 2FB099B72A8AD25100B20952 /* Localizable.xcstrings */; }; 977CF55C2AD2B92C006D9B54 /* XCTestApp in Frameworks */ = {isa = PBXBuildFile; productRef = 977CF55B2AD2B92C006D9B54 /* XCTestApp */; }; A95B6E652AF4298500919504 /* SpeziPersonalInfo in Frameworks */ = {isa = PBXBuildFile; productRef = A95B6E642AF4298500919504 /* SpeziPersonalInfo */; }; + A963ACAC2AF4683A00D745F2 /* SpeziValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A963ACAB2AF4683A00D745F2 /* SpeziValidationTests.swift */; }; + A963ACB02AF4692500D745F2 /* SpeziValidation in Frameworks */ = {isa = PBXBuildFile; productRef = A963ACAF2AF4692500D745F2 /* SpeziValidation */; }; A963ACB22AF4709400D745F2 /* XCUIApplication+Targets.swift in Sources */ = {isa = PBXBuildFile; fileRef = A963ACB12AF4709400D745F2 /* XCUIApplication+Targets.swift */; }; A97880972A4C4E6500150B2F /* ModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97880962A4C4E6500150B2F /* ModelTests.swift */; }; A97880992A4C524D00150B2F /* DefaultErrorDescriptionTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97880982A4C524D00150B2F /* DefaultErrorDescriptionTestView.swift */; }; A978809B2A4C52F100150B2F /* EnvironmentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A978809A2A4C52F100150B2F /* EnvironmentTests.swift */; }; A998A94F2A609A9E0030624D /* AsyncButtonTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A998A94E2A609A9E0030624D /* AsyncButtonTestView.swift */; }; - A9F9C4682AF2B9DD001122DD /* (null) in Frameworks */ = {isa = PBXBuildFile; }; + A99A65122AF57CA200E63582 /* FocusedValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A65112AF57CA200E63582 /* FocusedValidationTests.swift */; }; + A99A65152AF5923800E63582 /* ValidationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A65142AF5923800E63582 /* ValidationTests.swift */; }; + A9A3535B2AF60A9E00661848 /* DefaultValidationRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A3535A2AF60A9E00661848 /* DefaultValidationRules.swift */; }; A9F9C4692AF2B9DD001122DD /* XCTestExtensions in Frameworks */ = {isa = PBXBuildFile; productRef = 977CF55D2AD2B92C006D9B54 /* XCTestExtensions */; }; A9FBAE952AF445B6001E4AF1 /* SpeziViewsTargetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FBAE942AF445B6001E4AF1 /* SpeziViewsTargetsTests.swift */; }; A9FBAE982AF446F3001E4AF1 /* SpeziPersonalInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9FBAE972AF446F3001E4AF1 /* SpeziPersonalInfoTests.swift */; }; @@ -57,11 +61,15 @@ 2FA9486C29DE91130081C086 /* ViewsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewsTests.swift; sourceTree = ""; }; 2FB0758A299DDB9000C0B37F /* TestApp.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestApp.xctestplan; sourceTree = ""; }; 2FB099B72A8AD25100B20952 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + A963ACAB2AF4683A00D745F2 /* SpeziValidationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeziValidationTests.swift; sourceTree = ""; }; A963ACB12AF4709400D745F2 /* XCUIApplication+Targets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "XCUIApplication+Targets.swift"; sourceTree = ""; }; A97880962A4C4E6500150B2F /* ModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelTests.swift; sourceTree = ""; }; A97880982A4C524D00150B2F /* DefaultErrorDescriptionTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultErrorDescriptionTestView.swift; sourceTree = ""; }; A978809A2A4C52F100150B2F /* EnvironmentTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvironmentTests.swift; sourceTree = ""; }; A998A94E2A609A9E0030624D /* AsyncButtonTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncButtonTestView.swift; sourceTree = ""; }; + A99A65112AF57CA200E63582 /* FocusedValidationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FocusedValidationTests.swift; sourceTree = ""; }; + A99A65142AF5923800E63582 /* ValidationTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ValidationTests.swift; sourceTree = ""; }; + A9A3535A2AF60A9E00661848 /* DefaultValidationRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultValidationRules.swift; sourceTree = ""; }; A9FBAE942AF445B6001E4AF1 /* SpeziViewsTargetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeziViewsTargetsTests.swift; sourceTree = ""; }; A9FBAE972AF446F3001E4AF1 /* SpeziPersonalInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeziPersonalInfoTests.swift; sourceTree = ""; }; A9FBAE9B2AF44CCB001E4AF1 /* PersonalInfoViewsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersonalInfoViewsTests.swift; sourceTree = ""; }; @@ -73,6 +81,7 @@ buildActionMask = 2147483647; files = ( 2FA9486F29DE91A30081C086 /* SpeziViews in Frameworks */, + A963ACB02AF4692500D745F2 /* SpeziValidation in Frameworks */, 977CF55C2AD2B92C006D9B54 /* XCTestApp in Frameworks */, A95B6E652AF4298500919504 /* SpeziPersonalInfo in Frameworks */, ); @@ -83,7 +92,6 @@ buildActionMask = 2147483647; files = ( A9F9C4692AF2B9DD001122DD /* XCTestExtensions in Frameworks */, - A9F9C4682AF2B9DD001122DD /* (null) in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -118,6 +126,7 @@ A9FBAE942AF445B6001E4AF1 /* SpeziViewsTargetsTests.swift */, A9FBAE962AF446B2001E4AF1 /* PersonalInfoTests */, 2FA9485D29DE90710081C086 /* ViewsTests */, + A963ACAA2AF467F700D745F2 /* ValidationTests */, 2F6D139928F5F386007C25D6 /* Assets.xcassets */, 2FB099B72A8AD25100B20952 /* Localizable.xcstrings */, ); @@ -129,6 +138,7 @@ children = ( A963ACB12AF4709400D745F2 /* XCUIApplication+Targets.swift */, A9FBAE9A2AF44CB1001E4AF1 /* SpeziPersonalInfo */, + A99A65132AF5920C00E63582 /* SpeziValidation */, A9FBAE992AF44CAC001E4AF1 /* SpeziViews */, ); path = TestAppUITests; @@ -155,6 +165,24 @@ path = ViewsTests; sourceTree = ""; }; + A963ACAA2AF467F700D745F2 /* ValidationTests */ = { + isa = PBXGroup; + children = ( + A99A65112AF57CA200E63582 /* FocusedValidationTests.swift */, + A963ACAB2AF4683A00D745F2 /* SpeziValidationTests.swift */, + A9A3535A2AF60A9E00661848 /* DefaultValidationRules.swift */, + ); + path = ValidationTests; + sourceTree = ""; + }; + A99A65132AF5920C00E63582 /* SpeziValidation */ = { + isa = PBXGroup; + children = ( + A99A65142AF5923800E63582 /* ValidationTests.swift */, + ); + path = SpeziValidation; + sourceTree = ""; + }; A9FBAE962AF446B2001E4AF1 /* PersonalInfoTests */ = { isa = PBXGroup; children = ( @@ -202,6 +230,7 @@ 2FA9486E29DE91A30081C086 /* SpeziViews */, 977CF55B2AD2B92C006D9B54 /* XCTestApp */, A95B6E642AF4298500919504 /* SpeziPersonalInfo */, + A963ACAF2AF4692500D745F2 /* SpeziValidation */, ); productName = Example; productReference = 2F6D139228F5F384007C25D6 /* TestApp.app */; @@ -297,13 +326,16 @@ 2FA9486529DE90720081C086 /* ViewStateTestView.swift in Sources */, A9FBAE952AF445B6001E4AF1 /* SpeziViewsTargetsTests.swift in Sources */, 2FA9486929DE90720081C086 /* NameFieldsTestView.swift in Sources */, + A99A65122AF57CA200E63582 /* FocusedValidationTests.swift in Sources */, A998A94F2A609A9E0030624D /* AsyncButtonTestView.swift in Sources */, 2FA9486A29DE90720081C086 /* GeometryReaderTestView.swift in Sources */, 2FA9486729DE90720081C086 /* CanvasTestView.swift in Sources */, + A963ACAC2AF4683A00D745F2 /* SpeziValidationTests.swift in Sources */, A9FBAE982AF446F3001E4AF1 /* SpeziPersonalInfoTests.swift in Sources */, 2FA7382C290ADFAA007ACEB9 /* TestApp.swift in Sources */, 2FA9486629DE90720081C086 /* MarkdownViewTestView.swift in Sources */, A97880992A4C524D00150B2F /* DefaultErrorDescriptionTestView.swift in Sources */, + A9A3535B2AF60A9E00661848 /* DefaultValidationRules.swift in Sources */, 2F2D338729DE52EA00081B1D /* SpeziViewsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -316,6 +348,7 @@ 2FA9486D29DE91130081C086 /* ViewsTests.swift in Sources */, A963ACB22AF4709400D745F2 /* XCUIApplication+Targets.swift in Sources */, A97880972A4C4E6500150B2F /* ModelTests.swift in Sources */, + A99A65152AF5923800E63582 /* ValidationTests.swift in Sources */, A978809B2A4C52F100150B2F /* EnvironmentTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -725,6 +758,10 @@ isa = XCSwiftPackageProductDependency; productName = SpeziPersonalInfo; }; + A963ACAF2AF4692500D745F2 /* SpeziValidation */ = { + isa = XCSwiftPackageProductDependency; + productName = SpeziValidation; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 2F6D138A28F5F384007C25D6 /* Project object */; diff --git a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme index b07bb64..2cc1f32 100644 --- a/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme +++ b/Tests/UITests/UITests.xcodeproj/xcshareddata/xcschemes/TestApp.xcscheme @@ -48,6 +48,20 @@ ReferencedContainer = "container:../.."> + + + +