Skip to content
45 changes: 41 additions & 4 deletions ExampleApp/ExampleApp/InputFields/TextInputField.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,59 @@
import SwiftUI
import FormView

enum OuterValidationRule {
case duplicateName

var message: String {
switch self {
case .duplicateName:
return "This name already exists"
}
}
}

struct TextInputField: View {
let title: LocalizedStringKey
let text: Binding<String>
@Binding var text: String
let failedRules: [TextValidationRule]
@Binding var outerRules: [OuterValidationRule]
Comment thread
ilia-chub marked this conversation as resolved.
Outdated

var body: some View {
VStack(alignment: .leading) {
TextField(title, text: text)
TextField(title, text: $text)
.background(Color.white)
if failedRules.isEmpty == false {
Text(failedRules[0].message)
if let errorMessage = getErrorMessage() {
Comment thread
NatalyaLuzyanina marked this conversation as resolved.
Outdated
Text(errorMessage)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.red)
}
Spacer()
}
.frame(height: 50)
.onChange(of: text) { _ in
outerRules = []
}
}

private func getErrorMessage() -> String? {
if let message = failedRules.first?.message {
return message
} else if let message = outerRules.first?.message {
return message
} else {
return nil
}
}

init(
Comment thread
ilia-chub marked this conversation as resolved.
title: LocalizedStringKey,
text: Binding<String>,
failedRules: [TextValidationRule],
outerRules: Binding<[OuterValidationRule]> = .constant([])
) {
self.title = title
self._text = text
self.failedRules = failedRules
self._outerRules = outerRules
}
}
10 changes: 9 additions & 1 deletion ExampleApp/ExampleApp/UI/ContentScreen/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@ struct ContentView: View {
.myRule
]
) { failedRules in
TextInputField(title: "Name", text: $viewModel.name, failedRules: failedRules)
TextInputField(
title: "Name",
text: $viewModel.name,
failedRules: failedRules,
outerRules: $viewModel.nameOuterRules
)
}
FormField(
value: $viewModel.age,
Expand Down Expand Up @@ -57,6 +62,9 @@ struct ContentView: View {
Button("Validate") {
print("Form is valid: \(proxy.validate())")
}
Button("Apply name outer rules") {
viewModel.applyNameOuterRules()
}
}
.padding(.horizontal, 16)
.padding(.top, 40)
Expand Down
5 changes: 5 additions & 0 deletions ExampleApp/ExampleApp/UI/ContentScreen/ContentViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class ContentViewModel: ObservableObject {
@Published var age: String = ""
@Published var pass: String = ""
@Published var confirmPass: String = ""
Comment thread
ridebyhorse marked this conversation as resolved.
@Published var nameOuterRules: [OuterValidationRule] = []

private let coordinator: ContentCoordinator

Expand All @@ -20,6 +21,10 @@ class ContentViewModel: ObservableObject {
print("init ContentViewModel")
}

func applyNameOuterRules() {
nameOuterRules = [.duplicateName]
}

deinit {
print("deinit ContentViewModel")
}
Expand Down
4 changes: 2 additions & 2 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ let package = Package(
targets: ["FormView"])
],
dependencies: [
.package(url: "https://github.com/nalexn/ViewInspector", branch: "master")
.package(url: "https://github.com/nalexn/ViewInspector", exact: .init(0, 10, 1))
],
targets: [
.target(
Expand Down
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,92 @@ A banch of predefind rules for text validation is available via `TextValidationR
* equalTo - value equal to another value. Useful for password confirmation.
* etc...

### Outer Validation Rules
If you need to display validation errors from external services (e.g., a backend), follow these steps:
1. Create an `OuterValidationRule` enum:
```swift
enum OuterValidationRule {
case duplicateName

var message: String {
switch self {
case .duplicateName:
return "This name already exists"
}
}
}
```

2. Update the text field component:
```swift
struct TextInputField: View {
let title: LocalizedStringKey
@Binding var text: String
let failedRules: [TextValidationRule]
@Binding var outerRules: [OuterValidationRule]

var body: some View {
VStack(alignment: .leading) {
TextField(title, text: $text)
.background(Color.white)
if let errorMessage = getErrorMessage() {
Text(errorMessage)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.red)
}
Spacer()
}
.frame(height: 50)
.onChange(of: text) { _ in
outerRules = []
}
}

private func getErrorMessage() -> String? {
if let message = failedRules.first?.message {
return message
} else if let message = outerRules.first?.message {
return message
} else {
return nil
}
}

init(
title: LocalizedStringKey,
text: Binding<String>,
failedRules: [TextValidationRule],
outerRules: Binding<[OuterValidationRule]> = .constant([])
) {
self.title = title
self._text = text
self.failedRules = failedRules
self._outerRules = outerRules
}
}
```
3. Update the text field initialization in your view:
```swift
TextInputField(
title: "Name",
text: $viewModel.name,
failedRules: failedRules,
outerRules: $viewModel.nameOuterRules
)
```

4. In your ViewModel, declare a `@Published` property of type `OuterValidationRule` and update its rules as needed:
```swift
class ContentViewModel: ObservableObject {
@Published var nameOuterRules: [OuterValidationRule] = []

func applyNameOuterRules() {
nameOuterRules = [.duplicateName]
}
}
```


### Implementation Details
FormView doesn't use any external dependencies.

Expand Down
56 changes: 41 additions & 15 deletions Tests/FormViewTests/FormViewTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,61 +12,82 @@ import Combine
@testable import FormView

final class FormViewTests: XCTestCase {
@MainActor
func testPreventInvalidInput() throws {
var text1 = ""
var text2 = ""
let sut = InspectionWrapperView(
wrapped: FormView {
wrapped: FormView { _ in
ScrollView {
FormField(
value: Binding(get: { text1 }, set: { text1 = $0 }),
validationRules: [.digitsOnly]
rules: [TextValidationRule.digitsOnly(message: "")],
content: { _ in
TextField(text1, text: Binding(get: { text1 }, set: { text1 = $0 }))
}
)
.id(1)
FormField(value: Binding(get: { text2 }, set: { text2 = $0 }))
FormField(
value: Binding(get: { text2 }, set: { text2 = $0 }),
rules: [TextValidationRule.digitsOnly(message: "")],
content: { _ in
TextField(text2, text: Binding(get: { text2 }, set: { text2 = $0 }))
}
)
.id(2)
}
}
)

let exp = sut.inspection.inspect { view in
let scrollView = try view.find(ViewType.ScrollView.self)
let textField1 = try view.find(viewWithId: 1).textField()
let textField1 = try view.find(viewWithId: 1).find(ViewType.TextField.self)

text1 = "123"

try scrollView.callOnSubmit()
try textField1.callOnChange(newValue: "New Focus Field", index: 1)
try textField1.callOnChange(newValue: "123")
XCTAssertEqual(try textField1.input(), "123")

text1 = "123"
try textField1.callOnChange(newValue: "123_A")
XCTAssertEqual(try textField1.input(), text1)
XCTAssertNotEqual(try textField1.input(), "123_A")
}

ViewHosting.host(view: sut)
wait(for: [exp], timeout: 0.1)
}

@MainActor
func testSubmitTextField() throws {
var text1 = ""
var text2 = ""
let sut = InspectionWrapperView(
wrapped: FormView {
wrapped: FormView { _ in
ScrollView {
FormField(
value: Binding(get: { text1 }, set: { text1 = $0 }),
validationRules: [.digitsOnly]
rules: [TextValidationRule.digitsOnly(message: "")],
content: { _ in
TextField(text1, text: Binding(get: { text1 }, set: { text1 = $0 }))
}
)
.id(1)
FormField(value: Binding(get: { text2 }, set: { text2 = $0 }))
FormField(
value: Binding(get: { text2 }, set: { text2 = $0 }),
rules: [TextValidationRule.digitsOnly(message: "")],
content: { _ in
TextField(text2, text: Binding(get: { text2 }, set: { text2 = $0 }))
}
)
.id(2)
}
}
)

let exp = sut.inspection.inspect { view in
let scrollView = try view.find(ViewType.ScrollView.self)
let textField1 = try view.find(viewWithId: 1).textField()
let textField1 = try view.find(viewWithId: 1).find(ViewType.TextField.self)
// let formField2 = try view.find(viewWithId: 2).view(FormField.self).actualView()

try scrollView.callOnSubmit()
Expand All @@ -76,17 +97,22 @@ final class FormViewTests: XCTestCase {
XCTAssertTrue(true)
}

ViewHosting.host(view: sut.environment(\.focusField, "field1"))
ViewHosting.host(view: sut.environment(\.focusedFieldId, "1"))
wait(for: [exp], timeout: 0.1)
}

func testFocusNextField() throws {
var fieldStates = [FieldState(id: "1", isFocused: false), FieldState(id: "2", isFocused: false)]
let fieldStates = [
FieldState(id: "1", isFocused: true, onValidate: { true }),
FieldState(id: "2", isFocused: false, onValidate: { false })
]

var nextFocusField = fieldStates.focusNextField(currentFocusField: "")
XCTAssertEqual(nextFocusField, "1")
var nextFocusField = FocusService.getNextFocusFieldId(states: fieldStates, currentFocusField: "1")
nextFocusField = nextFocusField.trimmingCharacters(in: .whitespaces)
XCTAssertEqual(nextFocusField, "2")

nextFocusField = fieldStates.focusNextField(currentFocusField: "1")
nextFocusField = FocusService.getNextFocusFieldId(states: fieldStates, currentFocusField: "2")
nextFocusField = nextFocusField.trimmingCharacters(in: .whitespaces)
XCTAssertEqual(nextFocusField, "2")
}
}
Loading