A library for helping build high quality forms in SwiftUI and TCA
0

Configure Feed

Select the types of activity you want to include in your feed.

Add SwiftUI-style form submit delegate

Woodrow Melling (May 22, 2026, 12:40 PM -0600) d05d08ea ef593ebb

+162 -9
+46 -9
Sources/ComposableForms/Form.swift
··· 370 370 @Feature 371 371 public struct Form<Value> { 372 372 public init(fields: [FormFieldDefinition<Value>]) { 373 - self.fields = fields 373 + self.init(fields: fields, submitDelegate: { _ in }) 374 374 } 375 375 376 376 public init( 377 377 @FormFieldDefinitionBuilder<Value> fields: () -> [FormFieldDefinition<Value>] 378 378 ) { 379 - self.fields = fields() 379 + self.init(fields: fields()) 380 + } 381 + 382 + private init( 383 + fields: [FormFieldDefinition<Value>], 384 + submitDelegate: @escaping (Value) throws -> Void 385 + ) { 386 + self.fields = fields 387 + self.submitDelegate = submitDelegate 380 388 } 381 389 382 390 public static func Field<Root, FieldValue: Equatable>( ··· 494 502 case submittedForm 495 503 } 496 504 505 + public func onSubmit(_ action: @escaping (Value) throws -> Void) -> Self { 506 + Self(fields: self.fields, submitDelegate: action) 507 + } 508 + 497 509 private let fields: [FormFieldDefinition<Value>] 510 + private let submitDelegate: (Value) throws -> Void 498 511 499 512 public var body: some Feature { 513 + let submit: (Value) -> Void = { value in 514 + store.addTask { 515 + try self.submitDelegate(value) 516 + } 517 + } 500 518 let runAsyncValidation: (PendingAsyncValidation<Value>) -> Void = { validation in 501 519 store.addTask { 502 520 let errors = await validation.validateAsync(validation.value, validation.trigger) 521 + var submitValue: Value? 503 522 try store.modify { state in 504 523 guard validation.observedValue(state.value) == validation.validatedValue else { 505 524 return ··· 509 528 } else { 510 529 state.validation[validation.field] = FieldValidationState(errors: errors) 511 530 } 512 - state.finishSubmissionIfValidationSettled() 531 + if state.finishSubmissionIfValidationSettled() { 532 + submitValue = state.value 533 + } 534 + } 535 + if let submitValue { 536 + try self.submitDelegate(submitValue) 513 537 } 514 538 } 515 539 } ··· 536 560 runAsyncValidation: runAsyncValidation 537 561 ) 538 562 } 539 - state.updateSubmissionStatusAfterSubmit() 563 + if state.updateSubmissionStatusAfterSubmit() { 564 + submit(state.value) 565 + } 540 566 } 541 567 } 542 568 .onChange(of: store.focusedField) { oldField, newField, state in ··· 652 678 } 653 679 654 680 extension Form.State { 655 - fileprivate mutating func finishSubmissionIfValidationSettled() { 681 + fileprivate mutating func finishSubmissionIfValidationSettled() -> Bool { 656 682 guard self.submissionStatus == .submitting, !self.isValidating else { 657 - return 683 + return false 684 + } 685 + guard !self.hasErrors else { 686 + self.submissionStatus = .idle 687 + return false 658 688 } 659 - self.submissionStatus = self.hasErrors ? .idle : .submitted 689 + self.submissionStatus = .submitted 690 + return true 660 691 } 661 692 662 693 fileprivate func hasErrors(for field: AnyHashable) -> Bool { ··· 667 698 self.validation[field] != nil 668 699 } 669 700 670 - fileprivate mutating func updateSubmissionStatusAfterSubmit() { 701 + fileprivate mutating func updateSubmissionStatusAfterSubmit() -> Bool { 671 702 if self.isValidating { 672 703 self.submissionStatus = .submitting 704 + return false 673 705 } else { 674 - self.submissionStatus = self.hasErrors ? .idle : .submitted 706 + guard !self.hasErrors else { 707 + self.submissionStatus = .idle 708 + return false 709 + } 710 + self.submissionStatus = .submitted 711 + return true 675 712 } 676 713 } 677 714 }
+116
Tests/ComposableFormsTests/FormTests.swift
··· 1 1 import ComposableArchitecture2 2 + import Foundation 2 3 import Testing 3 4 @testable import ComposableForms 4 5 ··· 207 208 208 209 @Test 209 210 @MainActor 211 + func submittedFormCallsOnSubmitWhenValid() async { 212 + let value = TestValue(name: "Blob", status: "draft") 213 + let submitProbe = SubmitProbe<TestValue>() 214 + let store = TestStore(initialState: TestForm.State(value: value)) { 215 + TestForm() 216 + .onSubmit { submitProbe.submit($0) } 217 + } 218 + 219 + let task = store.send(.submittedForm) { 220 + $0.submissionStatus = .submitted 221 + $0.validation[FieldID.name] = .valid 222 + $0.validation[FieldID.status] = .valid 223 + } 224 + await task?.value 225 + 226 + #expect(submitProbe.values() == [value]) 227 + } 228 + 229 + @Test 230 + @MainActor 231 + func submittedFormDoesNotCallOnSubmitWhenInvalid() { 232 + let submitProbe = SubmitProbe<TestValue>() 233 + let store = TestStore(initialState: TestForm.State(value: TestValue())) { 234 + TestForm() 235 + .onSubmit { submitProbe.submit($0) } 236 + } 237 + 238 + store.send(.submittedForm) { 239 + $0.validation[FieldID.name] = .invalid(.nameRequired) 240 + $0.validation[FieldID.status] = .invalid(.statusRequired) 241 + } 242 + 243 + #expect(submitProbe.values().isEmpty) 244 + } 245 + 246 + @Test 247 + @MainActor 210 248 func valueChangeClearsSubmittedStatus() { 211 249 let store = TestStore( 212 250 initialState: TestForm.State( ··· 330 368 331 369 @Test 332 370 @MainActor 371 + func submittedFormCallsOnSubmitAfterAsyncValidationSucceeds() async { 372 + let submitProbe = SubmitProbe<AsyncTestValue>() 373 + let validationProbe = AsyncValidationProbe() 374 + let value = AsyncTestValue(username: "blob") 375 + let store = TestStore(initialState: AsyncTestForm.State(value: value)) { 376 + AsyncTestForm(probe: validationProbe) 377 + .onSubmit { submitProbe.submit($0) } 378 + } 379 + 380 + let task = store.send(.submittedForm) { 381 + $0.submissionStatus = .submitting 382 + $0.validation[AsyncFieldID.username] = .validating() 383 + } 384 + #expect(submitProbe.values().isEmpty) 385 + 386 + await validationProbe.waitForValidation() 387 + await validationProbe.finish(.valid) 388 + await task?.value 389 + let expectation = store.expect { 390 + $0.submissionStatus = .submitted 391 + $0.validation[AsyncFieldID.username] = .valid 392 + } 393 + await expectation?.value 394 + 395 + #expect(submitProbe.values() == [value]) 396 + } 397 + 398 + @Test 399 + @MainActor 333 400 func asyncValidationIgnoresStaleResults() async { 334 401 let probe = AsyncValidationProbe() 335 402 let store = TestStore( ··· 356 423 await probe.finish(.invalid(.usernameUnavailable)) 357 424 await task?.value 358 425 #expect(store.state.validation[AsyncFieldID.username] == nil) 426 + } 427 + 428 + @Test 429 + @MainActor 430 + func staleAsyncValidationDoesNotCallOnSubmit() async { 431 + let submitProbe = SubmitProbe<AsyncTestValue>() 432 + let validationProbe = AsyncValidationProbe() 433 + let store = TestStore( 434 + initialState: AsyncTestForm.State( 435 + value: AsyncTestValue(username: "blob") 436 + ) 437 + ) { 438 + AsyncTestForm(probe: validationProbe) 439 + .onSubmit { submitProbe.submit($0) } 440 + } 441 + 442 + let task = store.send(.submittedForm) { 443 + $0.submissionStatus = .submitting 444 + $0.validation[AsyncFieldID.username] = .validating() 445 + } 446 + await validationProbe.waitForValidation() 447 + 448 + store.modify { 449 + $0.value.username = "fresh" 450 + } changes: { 451 + $0.submissionStatus = .idle 452 + $0.validation.removeValue(forKey: AsyncFieldID.username) 453 + } 454 + 455 + await validationProbe.finish(.valid) 456 + await task?.value 457 + #expect(submitProbe.values().isEmpty) 359 458 } 360 459 361 460 @Test ··· 445 544 private typealias NestedTestForm = Form<NestedValue> 446 545 private typealias AsyncTestForm = Form<AsyncTestValue> 447 546 private typealias SubmitOnlyTestForm = Form<AsyncTestValue> 547 + 548 + private final class SubmitProbe<Value>: @unchecked Sendable { 549 + private let lock = NSLock() 550 + private var submittedValues: [Value] = [] 551 + 552 + func submit(_ value: Value) { 553 + self.lock.lock() 554 + defer { self.lock.unlock() } 555 + self.submittedValues.append(value) 556 + } 557 + 558 + func values() -> [Value] { 559 + self.lock.lock() 560 + defer { self.lock.unlock() } 561 + return self.submittedValues 562 + } 563 + } 448 564 449 565 private actor AsyncValidationProbe { 450 566 private var continuations: [CheckedContinuation<ValidationResult, Never>] = []