Skip to content

Commit e2c4408

Browse files
Update form with server state (#1451)
* Update FormState from server diff * Only update when the field is not focused * Reset focus before submit event is sent * Update changelog * Add PR link * Avoid unnecessary capture of FormModel * Receive elementChanged on MainActor --------- Signed-off-by: Brian Cardarella <[email protected]> Co-authored-by: Brian Cardarella <[email protected]>
1 parent 5c83cb6 commit e2c4408

File tree

14 files changed

+101
-20
lines changed

14 files changed

+101
-20
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- `LiveViewNative.SwiftUI.normalize_app_version/1`
1313

1414
## Changed
15+
- Submitting a form will remove focus from all fields (#1451)
1516

1617
## Fixed
18+
- Form elements will apply updates from a diff (#1451)
1719
- Updates to change-tracked properties no longer occur on the next RunLoop, fixing modal dismissal on macOS (#1450)
1820
- `+` characters are properly encoded as `%2B` in form events (#1449)
1921

Sources/LiveViewNative/Coordinators/LiveViewCoordinator.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ public class LiveViewCoordinator<R: RootRegistry>: ObservableObject {
5656
private(set) internal var eventSubject = PassthroughSubject<(String, Payload), Never>()
5757
private(set) internal var eventHandlers = Set<AnyCancellable>()
5858

59+
private(set) internal var liveViewModel = LiveViewModel()
60+
5961
init(
6062
session: LiveSessionCoordinator<R>,
6163
url: URL
@@ -396,7 +398,13 @@ public class LiveViewCoordinator<R: RootRegistry>: ObservableObject {
396398
private func handleJoinPayload(renderedPayload: Payload) {
397399
// todo: what should happen if decoding or parsing fails?
398400
self.rendered = try! Root(from: FragmentDecoder(data: renderedPayload))
401+
402+
// FIXME: LiveForm should send change event when restored from `liveViewModel`.
403+
// For now, we just clear the forms whenever the page reconnects.
404+
self.liveViewModel.clearForms()
405+
399406
self.document = try! LiveViewNativeCore.Document.parse(rendered.buildString())
407+
400408
self.document?.on(.changed) { [unowned self] doc, nodeRef in
401409
switch doc[nodeRef].data {
402410
case .root:

Sources/LiveViewNative/Live/LiveView.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,6 @@ public struct LiveView<
141141
>: View {
142142
@StateObject var session: LiveSessionCoordinator<R>
143143

144-
@StateObject private var liveViewModel = LiveViewModel()
145-
146144
@Environment(\.scenePhase) private var scenePhase
147145

148146
let phaseView: (LiveViewPhase<R>) -> PhaseView
@@ -223,7 +221,6 @@ public struct LiveView<
223221
.environment(\.stylesheet, session.stylesheet ?? .init(content: [], classes: [:]))
224222
.environment(\.reconnectLiveView, .init(baseURL: session.url, action: session.reconnect))
225223
.environmentObject(session)
226-
.environmentObject(liveViewModel)
227224
.task(priority: .userInitiated) {
228225
await session.connect()
229226
}

Sources/LiveViewNative/NavStackEntryView.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import LiveViewNativeCore
1111
struct NavStackEntryView<R: RootRegistry>: View {
1212
private let entry: LiveNavigationEntry<R>
1313
@ObservedObject private var coordinator: LiveViewCoordinator<R>
14-
@StateObject private var liveViewModel = LiveViewModel()
1514
@Environment(\.liveViewStateViews) private var liveViewStateViews
1615

1716
init(_ entry: LiveNavigationEntry<R>) {
@@ -21,7 +20,7 @@ struct NavStackEntryView<R: RootRegistry>: View {
2120

2221
var body: some View {
2322
elementTree
24-
.environmentObject(liveViewModel)
23+
.environmentObject(coordinator.liveViewModel)
2524
}
2625

2726
private func buildPhaseView(_ phase: LiveViewPhase<R>) -> some View {

Sources/LiveViewNative/Property Wrappers/FormState.swift

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ public struct FormState<Value: FormValue> {
6666
@Environment(\.formModel) private var formModel: FormModel?
6767
@Environment(\.coordinatorEnvironment) private var coordinator
6868

69+
/// Use this with the ``SwiftUI/View/focused(_:)`` modifier to prevent server-side updates while editing.
70+
@FocusState public var isFocused: Bool
71+
72+
/// Use this with `onEditingChanged` callbacks to prevent server-side updates while editing.
73+
///
74+
/// Prefer ``isFocused`` for elements that can be focused.
75+
@State public var isEditing: Bool = false
76+
6977
@Event("phx-change", type: "form") private var changeEvent
7078

7179
/// Creates a `FormState` property wrapper with a default value that will be used when no other value is present.
@@ -174,10 +182,19 @@ public struct FormState<Value: FormValue> {
174182
}
175183

176184
private func resolveMode() {
185+
let elementName = element.attributeValue(for: "name")
177186
if case .unknown = data.mode {
178187
if let formModel {
179-
if let elementName = element.attributeValue(for: "name") {
180-
data.setFormModel(formModel, elementName: elementName)
188+
if let elementName {
189+
data.bind(
190+
formModel,
191+
to: _element,
192+
elementName: elementName,
193+
attribute: valueAttribute,
194+
defaultValue: defaultValue,
195+
isFocused: $isFocused,
196+
isEditing: $isEditing
197+
)
181198
formModel.setInitialValue(initialValue, forName: elementName)
182199
data.mode = .form(formModel)
183200
} else {
@@ -222,11 +239,43 @@ extension FormState: DynamicProperty {
222239
private class FormStateData<Value: FormValue>: ObservableObject {
223240
var mode: Mode = .unknown
224241
private var cancellable: AnyCancellable?
242+
private var elementCancellable: AnyCancellable?
243+
private var focusCancellable: AnyCancellable?
225244

226-
func setFormModel(_ formModel: FormModel, elementName: String) {
245+
func bind(
246+
_ formModel: FormModel,
247+
to element: ObservedElement,
248+
elementName: String,
249+
attribute: AttributeName,
250+
defaultValue: Value,
251+
isFocused: FocusState<Bool>.Binding,
252+
isEditing: Binding<Bool>
253+
) {
254+
// Trigger a View update when the field's value changes.
227255
cancellable = formModel.formFieldWillChange
228256
.filter { $0 == elementName }
229257
.sink { [unowned self] _ in self.objectWillChange.send() }
258+
259+
// When the element updates from the server, sync the new value into the form.
260+
elementCancellable = element.projectedValue
261+
.receive(on: DispatchQueue.main)
262+
.sink { [weak self, weak formModel] _ in
263+
// ignore server updates if the field is focused.
264+
guard !isFocused.wrappedValue && !isEditing.wrappedValue else { return }
265+
formModel?.setServerValue(
266+
element.wrappedValue.attribute(named: attribute)
267+
.flatMap { Value.fromAttribute($0, on: element.wrappedValue) }
268+
?? defaultValue,
269+
forName: elementName
270+
)
271+
}
272+
273+
// Remove all focus from form fields when the form is submitted.
274+
focusCancellable = formModel.formWillSubmit
275+
.sink { _ in
276+
isFocused.wrappedValue = false
277+
isEditing.wrappedValue = false
278+
}
230279
}
231280

232281
enum Mode {

Sources/LiveViewNative/ViewModel.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ public class LiveViewModel: ObservableObject {
3030
return model
3131
}
3232
}
33+
34+
func clearForms() {
35+
self.forms.removeAll()
36+
}
3337
}
3438

3539
/// A form model stores the working copy of the data for a specific `<form>` element.
@@ -50,6 +54,9 @@ public class FormModel: ObservableObject, CustomDebugStringConvertible {
5054
@Published internal private(set) var data = [String: any FormValue]()
5155
var formFieldWillChange = PassthroughSubject<String, Never>()
5256

57+
/// A publisher that emits a value before sending the form submission event.
58+
var formWillSubmit = PassthroughSubject<(), Never>()
59+
5360
init(elementID: String) {
5461
self.elementID = elementID
5562
}
@@ -84,6 +91,7 @@ public class FormModel: ObservableObject, CustomDebugStringConvertible {
8491
/// See ``LiveViewCoordinator/pushEvent(type:event:value:target:)`` for more information.
8592
@MainActor
8693
public func sendSubmitEvent() async throws {
94+
formWillSubmit.send(())
8795
if let submitEvent = submitEvent {
8896
try await pushFormEvent(submitEvent)
8997
} else if let submitAction {
@@ -190,6 +198,11 @@ public class FormModel: ObservableObject, CustomDebugStringConvertible {
190198
}
191199
}
192200

201+
/// Set a value into the form's `data` without triggering change events.
202+
public func setServerValue(_ value: (some FormValue)?, forName name: String) {
203+
data[name] = value
204+
}
205+
193206
/// Sets the value in ``data`` if there is no value currently present.
194207
func setInitialValue(_ value: any FormValue, forName name: String) {
195208
guard !data.keys.contains(name)

Sources/LiveViewNative/Views/Controls and Indicators/Pickers/DatePicker.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,18 +62,22 @@ struct DatePicker<Root: RootRegistry>: View {
6262
SwiftUI.DatePicker(selection: $selection.date, in: start...end, displayedComponents: datePickerComponents) {
6363
$liveElement.children()
6464
}
65+
.focused(_selection.$isFocused)
6566
} else if let start {
6667
SwiftUI.DatePicker(selection: $selection.date, in: start..., displayedComponents: datePickerComponents) {
6768
$liveElement.children()
6869
}
70+
.focused(_selection.$isFocused)
6971
} else if let end {
7072
SwiftUI.DatePicker(selection: $selection.date, in: ...end, displayedComponents: datePickerComponents) {
7173
$liveElement.children()
7274
}
75+
.focused(_selection.$isFocused)
7376
} else {
7477
SwiftUI.DatePicker(selection: $selection.date, displayedComponents: datePickerComponents) {
7578
$liveElement.children()
7679
}
80+
.focused(_selection.$isFocused)
7781
}
7882
#endif
7983
}

Sources/LiveViewNative/Views/Controls and Indicators/Pickers/Picker.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ struct Picker<Root: RootRegistry>: View {
3636
@FormState("selection") private var selection: String?
3737

3838
var body: some View {
39-
SwiftUI.Picker.init(selection: $selection) {
39+
SwiftUI.Picker(selection: $selection) {
4040
ForEach($liveElement.childNodes(in: "content", default: true), id: \.id) { node in
4141
if let element = node.asElement() {
4242
// For simple Text elements, we can skip the slow element conversion, and convert directly to Text.
@@ -57,5 +57,6 @@ struct Picker<Root: RootRegistry>: View {
5757
} label: {
5858
$liveElement.children(in: "label")
5959
}
60+
.focused(_selection.$isFocused)
6061
}
6162
}

Sources/LiveViewNative/Views/Controls and Indicators/Value Inputs/Slider.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ struct Slider<Root: RootRegistry>: View {
9090
$liveElement.children(in: "minimumValueLabel")
9191
} maximumValueLabel: {
9292
$liveElement.children(in: "maximumValueLabel")
93+
} onEditingChanged: { isEditing in
94+
_value.isEditing = isEditing
9395
}
96+
.focused(_value.$isFocused)
9497
} else {
9598
SwiftUI.Slider(
9699
value: $value,
@@ -101,7 +104,10 @@ struct Slider<Root: RootRegistry>: View {
101104
$liveElement.children(in: "minimumValueLabel")
102105
} maximumValueLabel: {
103106
$liveElement.children(in: "maximumValueLabel")
107+
} onEditingChanged: { isEditing in
108+
_value.isEditing = isEditing
104109
}
110+
.focused(_value.$isFocused)
105111
}
106112
#endif
107113
}

Sources/LiveViewNative/Views/Controls and Indicators/Value Inputs/Stepper.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,17 @@ struct Stepper<Root: RootRegistry>: View {
6565
{
6666
SwiftUI.Stepper(value: $value, in: lowerBound...upperBound, step: step) {
6767
label
68+
} onEditingChanged: { isEditing in
69+
_value.isEditing = isEditing
6870
}
71+
.focused(_value.$isFocused)
6972
} else {
7073
SwiftUI.Stepper(value: $value, step: step) {
7174
label
75+
} onEditingChanged: { isEditing in
76+
_value.isEditing = isEditing
7277
}
78+
.focused(_value.$isFocused)
7379
}
7480
#endif
7581
}

0 commit comments

Comments
 (0)