import ComposableArchitecture2 import Dependencies import Foundation public typealias ValidationErrors = [AnyHashable: [ErrorMessage]] public typealias FieldValidationStates = [AnyHashable: FieldValidationState] private enum FormObservedChangeValidationDelayKey: DependencyKey { static var liveValue: Duration { .milliseconds(300) } static var testValue: Duration { .milliseconds(300) } } extension DependencyValues { public var formObservedChangeValidationDelay: Duration { get { self[FormObservedChangeValidationDelayKey.self] } set { self[FormObservedChangeValidationDelayKey.self] = newValue } } } public struct ValidationPolicy: Sendable, Hashable { public init(triggers: ValidationTriggers) { self.triggers = triggers } public static let all = Self(triggers: .all) public static let defaultAsync = Self(triggers: .all) public static let defaultSync = Self(triggers: .all) public static let fieldSubmitted = Self(triggers: .fieldSubmitted) public static let focusLoss = Self(triggers: .focusLoss) public static let formSubmitted = Self(triggers: .formSubmitted) public static let observedChange = Self(triggers: .observedChange) public static let userActions = Self(triggers: .userActions) public var triggers: ValidationTriggers public func runs(on trigger: ValidationTrigger) -> Bool { self.triggers.contains(trigger) } } public enum ValidationTrigger: Sendable, Hashable { case fieldSubmitted case focusLoss case formSubmitted case observedChange } public struct ValidationTriggers: Sendable, OptionSet, Hashable { public init(rawValue: Int) { self.rawValue = rawValue } public static let fieldSubmitted = Self(rawValue: 1 << 0) public static let focusLoss = Self(rawValue: 1 << 1) public static let formSubmitted = Self(rawValue: 1 << 2) public static let observedChange = Self(rawValue: 1 << 3) public static let all: Self = [ .fieldSubmitted, .focusLoss, .formSubmitted, .observedChange, ] public static let userActions: Self = [ .fieldSubmitted, .focusLoss, .formSubmitted, ] public let rawValue: Int public func contains(_ trigger: ValidationTrigger) -> Bool { switch trigger { case .fieldSubmitted: self.contains(ValidationTriggers.fieldSubmitted) case .focusLoss: self.contains(ValidationTriggers.focusLoss) case .formSubmitted: self.contains(ValidationTriggers.formSubmitted) case .observedChange: self.contains(ValidationTriggers.observedChange) } } } public struct ScopedFormFieldID: Hashable { public init(scope: AnyHashable, field: AnyHashable) { self.field = field self.scope = scope } public let field: AnyHashable public let scope: AnyHashable } public struct FieldValidationState: Sendable, Equatable { public static let valid = Self() public static func invalid(_ error: ErrorMessage) -> Self { .invalid([error]) } public static func invalid(_ errors: [ErrorMessage]) -> Self { Self(errors: errors) } public static func validating(errors: [ErrorMessage] = []) -> Self { Self(errors: errors, isValidating: true) } public init( errors: [ErrorMessage] = [], isValidating: Bool = false ) { self.errors = errors self.isValidating = isValidating } public var errors: [ErrorMessage] public var isValidating: Bool } public enum FormSubmissionStatus: Sendable, Equatable { case idle case submitted case submitting } public struct ErrorMessage: Sendable, Equatable { public static func == (lhs: Self, rhs: Self) -> Bool { lhs.message == rhs.message && lhs.subtitle == rhs.subtitle } public init( message: String, subtitle: String? = nil, originalError: (any Error)? = nil ) { self.message = message self.originalError = originalError self.subtitle = subtitle } public let message: String public let originalError: (any Error)? public let subtitle: String? } public enum ValidationResult: Sendable, Equatable { case invalid([ErrorMessage]) case valid public static func invalid(_ message: ErrorMessage) -> Self { .invalid([message]) } public static func invalid( _ message: String, subtitle: String? = nil, originalError: (any Error)? = nil ) -> Self { .invalid( ErrorMessage( message: message, subtitle: subtitle, originalError: originalError ) ) } } public struct Validation { public typealias AsyncRun = nonisolated(nonsending) (Value) async throws -> ValidationResult public enum Run { case async(AsyncRun) case sync((Value) throws -> ValidationResult) } public init( policy: ValidationPolicy? = nil, _ validate: @escaping (Value) -> ValidationResult ) { self.policy = policy ?? .defaultSync self.run = .sync(validate) } public init( policy: ValidationPolicy? = nil, _ validate: @escaping (Value) throws -> ValidationResult ) { self.policy = policy ?? .defaultSync self.run = .sync(validate) } public init( policy: ValidationPolicy? = nil, _ validate: nonisolated(nonsending) @escaping (Value) async throws -> ValidationResult ) { self.policy = policy ?? .defaultAsync self.run = .async(validate) } public init( _ validate: @escaping (Value) -> Bool, error: ErrorMessage, policy: ValidationPolicy? = nil ) { self.policy = policy ?? .defaultSync self.run = .sync { value in validate(value) ? .valid : .invalid(error) } } public init( _ validate: @escaping (Value) throws -> Bool, error: ErrorMessage, policy: ValidationPolicy? = nil ) { self.policy = policy ?? .defaultSync self.run = .sync { value in try validate(value) ? .valid : .invalid(error) } } public init( _ validate: @escaping (Value) -> Bool, error message: String, subtitle: String? = nil, policy: ValidationPolicy? = nil ) { self.init( validate, error: ErrorMessage(message: message, subtitle: subtitle), policy: policy ) } public init( _ validate: @escaping (Value) throws -> Bool, error message: String, subtitle: String? = nil, policy: ValidationPolicy? = nil ) { self.init( validate, error: ErrorMessage(message: message, subtitle: subtitle), policy: policy ) } public init( error: ErrorMessage, policy: ValidationPolicy? = nil, _ validate: @escaping (Value) -> Bool ) { self.init(validate, error: error, policy: policy) } public init( error: ErrorMessage, policy: ValidationPolicy? = nil, _ validate: @escaping (Value) throws -> Bool ) { self.init(validate, error: error, policy: policy) } public init( error message: String, subtitle: String? = nil, policy: ValidationPolicy? = nil, _ validate: @escaping (Value) -> Bool ) { self.init(validate, error: message, subtitle: subtitle, policy: policy) } public init( error message: String, subtitle: String? = nil, policy: ValidationPolicy? = nil, _ validate: @escaping (Value) throws -> Bool ) { self.init(validate, error: message, subtitle: subtitle, policy: policy) } public var policy: ValidationPolicy public var run: Run } @resultBuilder public enum ValidationBuilder { public static func buildExpression(_ validation: Validation) -> Validation { validation } public static func buildBlock(_ validations: Validation...) -> [Validation] { validations } } @resultBuilder public enum FormFieldDefinitionBuilder { public static func buildExpression( _ field: FormFieldDefinition ) -> [FormFieldDefinition] { [field] } public static func buildExpression( _ fields: [FormFieldDefinition] ) -> [FormFieldDefinition] { fields } public static func buildBlock( _ fields: [FormFieldDefinition]... ) -> [FormFieldDefinition] { fields.flatMap { $0 } } } public struct FormFieldDefinition { fileprivate init( id: AnyHashable, hasAsyncValidation: @escaping (ValidationTrigger) -> Bool, hasSkippedValidation: @escaping (ValidationTrigger) -> Bool, observedValue: @escaping (Value) -> AnyEquatable, validateAsync: nonisolated(nonsending) @escaping (Value, ValidationTrigger) async -> [ErrorMessage], validateSync: @escaping (Value, ValidationTrigger) -> ValidationEvaluation ) { self.hasAsyncValidation = hasAsyncValidation self.hasSkippedValidation = hasSkippedValidation self.id = id self.observedValue = observedValue self.validateAsync = validateAsync self.validateSync = validateSync } public init( _ keyPath: KeyPath, id: AnyHashable? = nil, @ValidationBuilder validations: () -> [Validation] = { [] } ) { self.init( id: id ?? AnyHashable(keyPath), value: keyPath, validations: validations ) } public init( id: AnyHashable, value keyPath: KeyPath, @ValidationBuilder validations: () -> [Validation] = { [] } ) { let validations = validations() self.hasAsyncValidation = { trigger in validations.hasAsyncValidation(on: trigger) } self.hasSkippedValidation = { trigger in validations.hasSkippedValidation(on: trigger) } self.id = id self.observedValue = { value in AnyEquatable(value[keyPath: keyPath]) } self.validateAsync = { value, trigger in await validations.validateAsync(value[keyPath: keyPath], on: trigger) } self.validateSync = { value, trigger in validations.validateSync(value[keyPath: keyPath], on: trigger) } } fileprivate var hasAsyncValidation: (ValidationTrigger) -> Bool fileprivate var hasSkippedValidation: (ValidationTrigger) -> Bool public var id: AnyHashable fileprivate var observedValue: (Value) -> AnyEquatable fileprivate var validateAsync: nonisolated(nonsending) (Value, ValidationTrigger) async -> [ErrorMessage] fileprivate var validateSync: (Value, ValidationTrigger) -> ValidationEvaluation } @Feature public struct Form { public init(fields: [FormFieldDefinition]) { self.init( fields: fields, submitDelegate: { _ in } ) } public init( @FormFieldDefinitionBuilder fields: () -> [FormFieldDefinition] ) { self.init(fields: fields()) } private init( fields: [FormFieldDefinition], submitDelegate: @escaping (Value) throws -> Void ) { self.fields = fields self.submitDelegate = submitDelegate var validationTaskIDs: [ValidationTaskKey: StoreTaskID] = [:] for field in fields { for trigger in validationTaskTriggers { validationTaskIDs[ ValidationTaskKey(field: field.id, trigger: trigger) ] = StoreTaskID() } } self.validationTaskIDs = validationTaskIDs } public static func Field( _ keyPath: KeyPath, id: AnyHashable? = nil, @ValidationBuilder validations: () -> [Validation] = { [] } ) -> FormFieldDefinition { FormFieldDefinition( keyPath, id: id, validations: validations ) } public static func Field( id: AnyHashable, value keyPath: KeyPath, @ValidationBuilder validations: () -> [Validation] = { [] } ) -> FormFieldDefinition { FormFieldDefinition( id: id, value: keyPath, validations: validations ) } public static func Scope( _ keyPath: KeyPath, id: AnyHashable? = nil, @FormFieldDefinitionBuilder fields: () -> [FormFieldDefinition] ) -> [FormFieldDefinition] { let scope = id ?? AnyHashable(keyPath) return fields().map { field in FormFieldDefinition( id: AnyHashable( ScopedFormFieldID( scope: scope, field: field.id ) ), hasAsyncValidation: field.hasAsyncValidation, hasSkippedValidation: field.hasSkippedValidation, observedValue: { value in field.observedValue(value[keyPath: keyPath]) }, validateAsync: { value, trigger in await field.validateAsync(value[keyPath: keyPath], trigger) }, validateSync: { value, trigger in field.validateSync(value[keyPath: keyPath], trigger) } ) } } public struct State { public init( value: Value, focusedField: AnyHashable? = nil, submissionStatus: FormSubmissionStatus = .idle, validation: FieldValidationStates = [:] ) { self.asyncValidationRuns = [:] self.focusedField = focusedField self.nextValidationRunID = 0 self.pendingObservedChangeValidationFields = [] self.submissionStatus = submissionStatus self.validation = validation self.value = value } public init( value: Value, focusedField: AnyHashable? = nil, validationErrors: ValidationErrors ) { self.init( value: value, focusedField: focusedField, submissionStatus: .idle, validation: validationErrors.mapValues(FieldValidationState.invalid) ) } public var canSubmit: Bool { !self.hasErrors && !self.isValidating } public var focusedField: AnyHashable? public var hasErrors: Bool { self.validation.values.contains { !$0.errors.isEmpty } } public var isSubmitted: Bool { self.submissionStatus == .submitted } public var isSubmitting: Bool { self.submissionStatus == .submitting } public var isValidating: Bool { self.validation.values.contains { $0.isValidating } } public var submissionStatus: FormSubmissionStatus public var validation: FieldValidationStates public var validationErrors: ValidationErrors { get { self.validation.compactMapValues { state in state.errors.isEmpty ? nil : state.errors } } set { self.validation = newValue.mapValues(FieldValidationState.invalid) } } public var value: Value fileprivate var asyncValidationRuns: [ValidationRunID: ActiveAsyncValidationRun] fileprivate var nextValidationRunID: Int fileprivate var pendingObservedChangeValidationFields: Set } public enum Action { case fieldSubmitted(AnyHashable) case submittedForm } public func onSubmit(_ action: @escaping (Value) throws -> Void) -> Self { Self( fields: self.fields, submitDelegate: action ) } @Dependency(\.continuousClock) private var clock @Dependency(\.formObservedChangeValidationDelay) private var observedChangeValidationDelay @StoreTaskID private var observedChangeValidationTask private let fields: [FormFieldDefinition] private let submitDelegate: (Value) throws -> Void private let validationTaskIDs: [ValidationTaskKey: StoreTaskID] public var body: some Feature { let _ = Self._logChanges() let submit: (Value) -> Void = { value in store.addTask { try self.submitDelegate(value) } } let runAsyncValidation: (PendingAsyncValidation) -> Void = { validation in store.addTask(id: self.validationTaskID(for: validation)) { let errors = await validation.validateAsync(validation.value, validation.trigger) try Task.checkCancellation() var submitValue: Value? try store.modify { state in let isSubmitRun = state.finishAsyncValidation( validation, errors: errors ) self.refreshValidatingState(for: validation.field, state: &state) if isSubmitRun, state.finishSubmissionIfValidationSettled() { submitValue = state.value } } if let submitValue { try self.submitDelegate(submitValue) } } } let cancelAsyncValidationTasks: (AnyHashable) -> Void = { field in let validationTaskIDs = self.validationTaskIDs(for: field) store.addTask { for validationTaskID in validationTaskIDs { validationTaskID.cancel() } } store.addTask(id: self.observedChangeValidationTask) {} } let scheduleDebouncedObservedValidation: () -> Void = { store.addTask(id: self.observedChangeValidationTask) { try await self.clock.sleep(for: self.observedChangeValidationDelay) var pendingAsyncValidations: [PendingAsyncValidation] = [] try store.modify { state in let fields = state.pendingObservedChangeValidationFields state.pendingObservedChangeValidationFields.removeAll() for field in fields { self.validate( field, state: &state, trigger: .observedChange, runAsyncValidation: { validation in pendingAsyncValidations.append(validation) }, cancelAsyncValidationTasks: cancelAsyncValidationTasks ) } } for validation in pendingAsyncValidations { let errors = await validation.validateAsync(validation.value, validation.trigger) try Task.checkCancellation() var submitValue: Value? try store.modify { state in let isSubmitRun = state.finishAsyncValidation( validation, errors: errors ) self.refreshValidatingState(for: validation.field, state: &state) if isSubmitRun, state.finishSubmissionIfValidationSettled() { submitValue = state.value } } if let submitValue { try self.submitDelegate(submitValue) } } } } Update { state, action in switch action { case let .fieldSubmitted(field): if state.focusedField != field { self.validate( field, state: &state, trigger: .fieldSubmitted, runAsyncValidation: runAsyncValidation, cancelAsyncValidationTasks: cancelAsyncValidationTasks ) } state.focusedField = self.nextField(after: field) case .submittedForm: for field in self.fields { self.validate( field.id, state: &state, trigger: .formSubmitted, runAsyncValidation: runAsyncValidation, cancelAsyncValidationTasks: cancelAsyncValidationTasks ) } if state.updateSubmissionStatusAfterSubmit() { submit(state.value) } } } .onChange(of: store.focusedField) { oldField, newField, state in guard let oldField, oldField != newField else { return } self.validate( oldField, state: &state, trigger: .focusLoss, runAsyncValidation: runAsyncValidation, cancelAsyncValidationTasks: cancelAsyncValidationTasks ) } .onChange(of: self.snapshot(store.value)) { oldSnapshot, newSnapshot, state in let changedFields = self.changedFields(from: oldSnapshot, to: newSnapshot) if !changedFields.isEmpty { state.submissionStatus = .idle } for field in changedFields { state.cancelAsyncValidationRuns(for: field) cancelAsyncValidationTasks(field) if state.hasErrors(for: field) { self.validate( field, state: &state, trigger: .observedChange, runAsyncValidation: runAsyncValidation, cancelAsyncValidationTasks: cancelAsyncValidationTasks ) } else { state.validation[field] = nil state.pendingObservedChangeValidationFields.insert(field) scheduleDebouncedObservedValidation() } } } } private func changedFields( from oldSnapshot: FormFieldSnapshot, to newSnapshot: FormFieldSnapshot ) -> [AnyHashable] { self.fields.compactMap { field in oldSnapshot.values[field.id] != newSnapshot.values[field.id] ? field.id : nil } } private func nextField(after id: AnyHashable) -> AnyHashable? { guard let index = self.fields.firstIndex(where: { $0.id == id }) else { return nil } let nextIndex = self.fields.index(after: index) guard nextIndex != self.fields.endIndex else { return nil } return self.fields[nextIndex].id } private func snapshot(_ value: Value) -> FormFieldSnapshot { FormFieldSnapshot( values: Dictionary( uniqueKeysWithValues: self.fields.map { field in (field.id, field.observedValue(value)) } ) ) } private func validate( _ field: AnyHashable, state: inout State, trigger: ValidationTrigger, runAsyncValidation: (PendingAsyncValidation) -> Void, cancelAsyncValidationTasks: (AnyHashable) -> Void ) { guard let definition = self.fields.first(where: { $0.id == field }) else { return } state.pendingObservedChangeValidationFields.remove(field) if trigger != .observedChange { state.cancelAsyncValidationRuns(for: field) cancelAsyncValidationTasks(field) } let syncResult = definition.validateSync(state.value, trigger) guard syncResult.errors.isEmpty else { state.validation[field] = .invalid(syncResult.errors) return } guard definition.hasAsyncValidation(trigger) else { if syncResult.didRun, !definition.hasSkippedValidation(trigger) { state.validation[field] = .valid } else { state.validation[field] = nil } return } let value = state.value let validatedValue = definition.observedValue(value) let runID = state.startAsyncValidation( field: field, isSubmitRun: trigger == .formSubmitted, validatedValue: validatedValue ) runAsyncValidation( PendingAsyncValidation( field: field, id: runID, marksValid: !definition.hasSkippedValidation(trigger), observedValue: definition.observedValue, validatedValue: validatedValue, validateAsync: definition.validateAsync, trigger: trigger, value: value ) ) } private func validationTaskIDs(for field: AnyHashable) -> [StoreTaskID] { validationTaskTriggers.compactMap { trigger in self.validationTaskID(for: field, trigger: trigger) } } private func refreshValidatingState(for field: AnyHashable, state: inout State) { guard let definition = self.fields.first(where: { $0.id == field }) else { return } let currentValue = definition.observedValue(state.value) let isValidatingCurrentValue = state.asyncValidationRuns.values.contains { run in run.field == field && run.validatedValue == currentValue } if isValidatingCurrentValue { state.validation[field] = .validating( errors: state.validation[field]?.errors ?? [] ) } else if var validation = state.validation[field], validation.isValidating { validation.isValidating = false state.validation[field] = validation.errors.isEmpty ? .valid : validation } } private func validationTaskID( for field: AnyHashable, trigger: ValidationTrigger ) -> StoreTaskID? { self.validationTaskIDs[ ValidationTaskKey(field: field, trigger: trigger) ] } private func validationTaskID( for validation: PendingAsyncValidation ) -> StoreTaskID? { return self.validationTaskID( for: validation.field, trigger: validation.trigger ) } } extension Form.State: Equatable where Value: Equatable {} private struct ActiveAsyncValidationRun: Equatable { var field: AnyHashable var isSubmitRun: Bool var validatedValue: AnyEquatable } private struct PendingAsyncValidation { var field: AnyHashable var id: ValidationRunID var marksValid: Bool var observedValue: (Value) -> AnyEquatable var validatedValue: AnyEquatable var validateAsync: nonisolated(nonsending) (Value, ValidationTrigger) async -> [ErrorMessage] var trigger: ValidationTrigger var value: Value } private struct ValidationRunID: Hashable { var rawValue: Int } private struct ValidationTaskKey: Hashable { var field: AnyHashable var trigger: ValidationTrigger } private let validationTaskTriggers: [ValidationTrigger] = [ .fieldSubmitted, .focusLoss, .formSubmitted, .observedChange, ] extension Form.State { fileprivate mutating func cancelAsyncValidationRuns(for field: AnyHashable) { self.asyncValidationRuns = self.asyncValidationRuns.filter { _, run in run.field != field } } fileprivate mutating func finishAsyncValidation( _ validation: PendingAsyncValidation, errors: [ErrorMessage] ) -> Bool { guard let run = self.asyncValidationRuns.removeValue(forKey: validation.id) else { return false } guard validation.observedValue(self.value) == validation.validatedValue else { return run.isSubmitRun } if errors.isEmpty, !validation.marksValid { self.validation[validation.field] = nil } else { self.validation[validation.field] = FieldValidationState(errors: errors) } return run.isSubmitRun } fileprivate mutating func finishSubmissionIfValidationSettled() -> Bool { guard self.submissionStatus == .submitting, !self.hasSubmitValidationRuns else { return false } guard !self.hasErrors else { self.submissionStatus = .idle return false } self.submissionStatus = .submitted return true } fileprivate var hasSubmitValidationRuns: Bool { self.asyncValidationRuns.values.contains { $0.isSubmitRun } } fileprivate func hasErrors(for field: AnyHashable) -> Bool { !(self.validation[field]?.errors ?? []).isEmpty } fileprivate mutating func startAsyncValidation( field: AnyHashable, isSubmitRun: Bool, validatedValue: AnyEquatable ) -> ValidationRunID { let runID = ValidationRunID(rawValue: self.nextValidationRunID) self.nextValidationRunID += 1 self.asyncValidationRuns[runID] = ActiveAsyncValidationRun( field: field, isSubmitRun: isSubmitRun, validatedValue: validatedValue ) self.validation[field] = .validating() return runID } fileprivate mutating func updateSubmissionStatusAfterSubmit() -> Bool { if self.hasSubmitValidationRuns { self.submissionStatus = .submitting return false } else { guard !self.hasErrors else { self.submissionStatus = .idle return false } self.submissionStatus = .submitted return true } } } private struct AnyEquatable: Equatable { init(_ value: Value) { self.value = value self.isEqual = { other in guard let other = other as? Value else { return false } return value == other } } private let value: Any private let isEqual: (Any) -> Bool static func == (lhs: Self, rhs: Self) -> Bool { lhs.isEqual(rhs.value) } } private struct FormFieldSnapshot: Equatable { var values: [AnyHashable: AnyEquatable] } private struct ValidationEvaluation { var didRun = false var errors: [ErrorMessage] = [] } private extension Array { func hasAsyncValidation(on trigger: ValidationTrigger) -> Bool where Element == Validation { self.contains { validation in guard case .async = validation.run else { return false } return validation.policy.runs(on: trigger) } } func hasSkippedValidation(on trigger: ValidationTrigger) -> Bool where Element == Validation { self.contains { validation in !validation.policy.runs(on: trigger) } } nonisolated(nonsending) func validateAsync( _ value: Value, on trigger: ValidationTrigger ) async -> [ErrorMessage] where Element == Validation { var errors: [ErrorMessage] = [] for validation in self { guard case let .async(validate) = validation.run else { continue } guard validation.policy.runs(on: trigger) else { continue } do { switch try await validate(value) { case let .invalid(messages): errors.append(contentsOf: messages) case .valid: break } } catch { errors.append(ErrorMessage(error)) } } return errors } func validateSync( _ value: Value, on trigger: ValidationTrigger ) -> ValidationEvaluation where Element == Validation { var result = ValidationEvaluation() for validation in self { guard case let .sync(validate) = validation.run else { continue } guard validation.policy.runs(on: trigger) else { continue } result.didRun = true do { switch try validate(value) { case let .invalid(messages): result.errors.append(contentsOf: messages) case .valid: break } } catch { result.errors.append(ErrorMessage(error)) } } return result } } private extension ErrorMessage { init(_ error: any Error) { self.init( message: error.localizedDescription, originalError: error ) } }