Skip to content

Commit b9ea8ce

Browse files
ugur-vaadintomivirkkiclaude
authored
refactor!: emit form filler field value events per field (#9593)
## Description This PR - updates the listener name (`addFieldValueChangedListener` -> `addFieldValueChangeListener`) - adds the listener interface `FieldValueChangeListener` and event class `FieldValueChangeEvent` - updates the listener API to fire one `FieldValueChangeEvent` per changed field Based on Form Filler DX test session findings. > [!WARNING] > Breaking change. `addFieldValueChangedListener(SerializableConsumer<List<FieldValueChange>>)` is replaced by `addFieldValueChangeListener(FieldValueChangeListener)`. The listener now fires once per changed field with a `FieldValueChangeEvent` instead of once per turn with a `List<FieldValueChange>`. The `FieldValueChange` record is removed. No related issue. ## Type of change - [ ] Bugfix - [ ] Feature - [x] Refactor ## Checklist - [x] I have read the contribution guide: https://vaadin.com/docs/latest/contributing/overview - [x] I have added a description following the guideline. - [ ] The issue is created in the corresponding repository and I have referenced it. - [x] I have added tests to ensure my change is effective and works as intended. - [x] New and existing tests are passing locally with my change. - [x] I have performed self-review and corrected misspellings. --------- Co-authored-by: Tomi Virkki <virkki@vaadin.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0ad1e04 commit b9ea8ce

5 files changed

Lines changed: 553 additions & 236 deletions

File tree

vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FieldValueChange.java

Lines changed: 0 additions & 40 deletions
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.component.ai.form;
17+
18+
import java.io.Serializable;
19+
import java.util.Objects;
20+
21+
import com.vaadin.flow.component.HasValue;
22+
23+
/**
24+
* Fired by {@link FormAIController} once per field whose value changed during a
25+
* successful AI turn — either written by the LLM or by a cascade. Events fire
26+
* in document order, after every field's post-turn value has been applied. A
27+
* field whose post-turn value equals its pre-turn value (by
28+
* {@link Objects#equals(Object, Object)}) does not produce an event. No events
29+
* fire when the turn ended in error.
30+
*
31+
* @since 25.3
32+
*/
33+
public final class FieldValueChangeEvent implements Serializable {
34+
35+
private final transient FormAIController source;
36+
private final HasValue<?, ?> field;
37+
@SuppressWarnings("java:S1948")
38+
private final Object oldValue;
39+
@SuppressWarnings("java:S1948")
40+
private final Object newValue;
41+
42+
FieldValueChangeEvent(FormAIController source, HasValue<?, ?> field,
43+
Object oldValue, Object newValue) {
44+
this.source = Objects.requireNonNull(source, "Source must not be null");
45+
this.field = Objects.requireNonNull(field, "Field must not be null");
46+
this.oldValue = oldValue;
47+
this.newValue = newValue;
48+
}
49+
50+
/**
51+
* Returns the controller that produced this event. Listeners can use this
52+
* to call back into the controller (e.g.
53+
* {@link FormAIController#showFieldHighlight}) without capturing it from
54+
* the registration site.
55+
* <p>
56+
* The source is transient, so it is {@code null} on an event restored from
57+
* a serialized session.
58+
*
59+
* @return the source controller, or {@code null} if this event was
60+
* deserialized
61+
*/
62+
public FormAIController getSource() {
63+
return source;
64+
}
65+
66+
/**
67+
* Returns the field whose value changed during this turn.
68+
*
69+
* @return the changed field, never {@code null}
70+
*/
71+
@SuppressWarnings("java:S1452")
72+
public HasValue<?, ?> getField() {
73+
return field;
74+
}
75+
76+
/**
77+
* Returns the field's value at the start of the turn, before the LLM ran.
78+
* For a field that was hidden at turn start and revealed during the turn,
79+
* this is the field's actual pre-turn value rather than {@code null}. For a
80+
* field added to the form during the turn, this is the field's
81+
* {@link HasValue#getEmptyValue() empty value} — the field had no pre-turn
82+
* value to report.
83+
*
84+
* @return the pre-turn value, possibly {@code null}
85+
*/
86+
public Object getOldValue() {
87+
return oldValue;
88+
}
89+
90+
/**
91+
* Returns the field's value at the end of the turn, after the LLM's writes
92+
* and any cascading value-change listeners have settled.
93+
*
94+
* @return the post-turn value, possibly {@code null}
95+
*/
96+
public Object getNewValue() {
97+
return newValue;
98+
}
99+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2000-2026 Vaadin Ltd.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
package com.vaadin.flow.component.ai.form;
17+
18+
import java.io.Serializable;
19+
20+
/**
21+
* Listener for {@link FieldValueChangeEvent}s fired by a
22+
* {@link FormAIController} after a successful AI turn. One event is delivered
23+
* per changed field, in document order.
24+
*
25+
* @since 25.3
26+
*/
27+
@FunctionalInterface
28+
public interface FieldValueChangeListener extends Serializable {
29+
30+
/**
31+
* Invoked once per changed field after the AI turn has settled.
32+
*
33+
* @param event
34+
* the event describing the change, never {@code null}
35+
*/
36+
void onFieldValueChange(FieldValueChangeEvent event);
37+
}

vaadin-ai-components-flow-parent/vaadin-ai-components-flow/src/main/java/com/vaadin/flow/component/ai/form/FormAIController.java

Lines changed: 70 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@
4747
import com.vaadin.flow.component.ai.provider.LLMProvider;
4848
import com.vaadin.flow.data.binder.Binder;
4949
import com.vaadin.flow.data.selection.MultiSelect;
50-
import com.vaadin.flow.function.SerializableConsumer;
5150
import com.vaadin.flow.internal.JacksonUtils;
5251
import com.vaadin.flow.shared.Registration;
5352

@@ -155,10 +154,10 @@
155154
*
156155
* <p>
157156
* <b>Change tracking and highlight:</b> a listener registered through
158-
* {@link #addFieldValueChangedListener(SerializableConsumer)} fires once per
159-
* successful turn with the fields whose value changed during the turn — the
160-
* common driver for {@link #showFieldHighlight(HasValue)} /
161-
* {@link #hideFieldHighlight} to flash the AI's edits in the UI.
157+
* {@link #addFieldValueChangeListener(FieldValueChangeListener)} fires once per
158+
* field whose value changed during a successful turn — the common driver for
159+
* {@link #showFieldHighlight(HasValue)} / {@link #hideFieldHighlight} to flash
160+
* the AI's edits in the UI.
162161
* </p>
163162
*
164163
* <p>
@@ -269,7 +268,7 @@ spans multiple fields (e.g. start date must precede end date). \
269268
* {@link #hideFieldHighlight}.
270269
*/
271270
private final Map<HasValue<?, ?>, Registration> highlightedFields = new HashMap<>();
272-
private final List<SerializableConsumer<List<FieldValueChange>>> fieldValuesChangedListeners = new ArrayList<>();
271+
private final List<FieldValueChangeListener> fieldValueChangeListeners = new ArrayList<>();
273272

274273
/**
275274
* Creates a new form AI controller for the given container. Fields are
@@ -613,40 +612,44 @@ public boolean isFieldValuesHidden() {
613612
}
614613

615614
/**
616-
* Registers a listener that is invoked once per successful turn with the
617-
* fields whose value differs from what was read at the start of the turn.
615+
* Registers a listener that is invoked once per field whose value changed
616+
* during a successful AI turn. The listener fires once per changed field,
617+
* in document order, after every field's post-turn value has been applied.
618618
* Comparison is by {@link Objects#equals(Object, Object)} so multi-select
619619
* sets, dates, and other value-objects work naturally.
620620
* <p>
621-
* Multiple listeners are supported and fire in registration order. If one
622-
* listener throws, the exception is logged and the remaining listeners
623-
* still fire.
621+
* Multiple listeners are supported. For each changed field, every listener
622+
* fires in registration order before the next field's event is dispatched.
623+
* If one listener throws, the exception is logged and the remaining
624+
* listeners still fire — both for that change and for subsequent changes in
625+
* the same turn.
624626
* <p>
625-
* Only non-ignored fields are tracked, and only changed fields appear in
626-
* the list. A field's pre-turn value is captured regardless of its current
627-
* visibility, so a value cascaded into a freshly-revealed field is reported
628-
* with the field's real pre-turn value rather than a spurious {@code null}.
629-
* The listener is not called when the turn ended in error or when no field
630-
* changed. The list iterates in document order; modifying it has no effect
631-
* on the controller.
627+
* Only non-ignored fields are tracked, and only fields whose value differs
628+
* at end-of-turn produce events. A field's pre-turn value is captured
629+
* regardless of its current visibility, so a value cascaded into a
630+
* freshly-revealed field is reported with the field's real pre-turn value
631+
* rather than a spurious {@code null}. A field added to the form during the
632+
* turn is compared against its {@link HasValue#getEmptyValue() empty
633+
* value}. No events fire when the turn ended in error.
632634
* <p>
633-
* The listener runs on the UI thread with the session lock held, so it can
635+
* Listeners run on the UI thread with the session lock held, so they can
634636
* update components and call {@link #showFieldHighlight} /
635637
* {@link #hideFieldHighlight} directly without {@code ui.access(...)}. A
636638
* typical use is to flash the AI's edits by calling
637-
* {@code showFieldHighlight} on every changed field.
639+
* {@code showFieldHighlight} on every event's
640+
* {@link FieldValueChangeEvent#getField field}.
638641
*
639642
* @param listener
640643
* the listener to register, not {@code null}
641644
* @return a {@link Registration} that removes the listener when called
642645
* @throws NullPointerException
643646
* if {@code listener} is {@code null}
644647
*/
645-
public Registration addFieldValueChangedListener(
646-
SerializableConsumer<List<FieldValueChange>> listener) {
648+
public Registration addFieldValueChangeListener(
649+
FieldValueChangeListener listener) {
647650
Objects.requireNonNull(listener, "Listener must not be null");
648-
fieldValuesChangedListeners.add(listener);
649-
return () -> fieldValuesChangedListeners.remove(listener);
651+
fieldValueChangeListeners.add(listener);
652+
return () -> fieldValueChangeListeners.remove(listener);
650653
}
651654

652655
/**
@@ -792,7 +795,7 @@ private void refreshValueOptionsBindings() {
792795
@Override
793796
public void onResponse(Throwable error) {
794797
try {
795-
fireFieldValuesChanged(error);
798+
fireFieldValueChanges(error);
796799
} finally {
797800
// Unlock regardless of success or failure: locks set in onRequest
798801
// must be released so the user can edit again. The failure path
@@ -804,7 +807,7 @@ public void onResponse(Throwable error) {
804807
/**
805808
* Captures the current value of every known field before the LLM runs. The
806809
* snapshot is consulted in {@link #onResponse} to compute the before /
807-
* after diff for {@link #addFieldValueChangedListener}. Skipped when no
810+
* after diff for {@link #addFieldValueChangeListener}. Skipped when no
808811
* listener is registered to avoid copying values that no one will read.
809812
* <p>
810813
* Hidden and disabled fields are included so a value cascaded into a field
@@ -813,7 +816,7 @@ public void onResponse(Throwable error) {
813816
*/
814817
private void snapshotPreTurnValues() {
815818
preTurnValues.clear();
816-
if (fieldValuesChangedListeners.isEmpty()) {
819+
if (fieldValueChangeListeners.isEmpty()) {
817820
return;
818821
}
819822
for (var field : collectKnownFields()) {
@@ -822,43 +825,58 @@ private void snapshotPreTurnValues() {
822825
}
823826

824827
/**
825-
* Builds the change list from the pre-turn snapshot and the post-turn value
826-
* of every known field, then invokes every registered listener if anything
827-
* changed. The post-turn walk picks up fields that were hidden (or absent)
828-
* at turn start but became visible / were added during the turn, so
829-
* visibility cascades report their value changes correctly. On error the
830-
* snapshot is discarded and no listener fires — the application learns
831-
* about errors through the orchestrator's response listener instead. A
832-
* throwing listener is logged and otherwise ignored so subsequent listeners
833-
* still fire and the rest of the response lifecycle (notably
828+
* Walks the pre-turn snapshot against the post-turn value of every known
829+
* field and fires one {@link FieldValueChangeEvent} per changed field, in
830+
* document order. The post-turn walk picks up fields that were hidden (or
831+
* absent) at turn start but became visible / were added during the turn, so
832+
* visibility cascades report their value changes correctly. A field with no
833+
* snapshot entry (added mid-turn) compares against its empty value, so
834+
* adding a field that keeps its empty value does not produce an event.
835+
* <p>
836+
* The diff is materialised before any listener runs, so a listener that
837+
* writes to a tracked field cannot retroactively change the
838+
* {@code newValue} another field's event carries. The listener set is also
839+
* snapshotted once per turn, so adding or removing listeners mid-dispatch
840+
* (including a listener removing itself) affects only subsequent turns, not
841+
* the rest of the current turn.
842+
* <p>
843+
* On error the snapshot is discarded and no events fire — the application
844+
* learns about errors through the orchestrator's response listener instead.
845+
* A throwing listener is logged and otherwise ignored so subsequent
846+
* listeners still fire, subsequent change events in the same turn still
847+
* fire, and the rest of the response lifecycle (notably
834848
* {@link #unlockFields}) still runs.
835849
*/
836-
private void fireFieldValuesChanged(Throwable error) {
850+
private void fireFieldValueChanges(Throwable error) {
837851
if (preTurnValues.isEmpty() || error != null) {
838852
preTurnValues.clear();
839853
return;
840854
}
841-
var changes = new ArrayList<FieldValueChange>();
855+
var events = new ArrayList<FieldValueChangeEvent>();
842856
for (var field : collectKnownFields()) {
843-
var oldValue = preTurnValues.get(field);
857+
var oldValue = preTurnValues.containsKey(field)
858+
? preTurnValues.get(field)
859+
: field.getEmptyValue();
844860
var newValue = field.getValue();
845861
if (!Objects.equals(oldValue, newValue)) {
846-
changes.add(new FieldValueChange(field, oldValue, newValue));
862+
events.add(new FieldValueChangeEvent(this, field, oldValue,
863+
newValue));
847864
}
848865
}
849866
preTurnValues.clear();
850-
if (changes.isEmpty()) {
867+
if (events.isEmpty()) {
851868
return;
852869
}
853-
// Snapshot the list before iterating so a listener that adds or
854-
// removes listeners (its own Registration included) doesn't break
855-
// the dispatch.
856-
for (var listener : List.copyOf(fieldValuesChangedListeners)) {
857-
try {
858-
listener.accept(changes);
859-
} catch (Exception ex) {
860-
LOGGER.warn("Field-values-changed listener threw an exception",
861-
ex);
870+
var snapshot = List.copyOf(fieldValueChangeListeners);
871+
for (var event : events) {
872+
for (var listener : snapshot) {
873+
try {
874+
listener.onFieldValueChange(event);
875+
} catch (Exception ex) {
876+
LOGGER.warn(
877+
"Field-value-change listener threw an exception",
878+
ex);
879+
}
862880
}
863881
}
864882
}
@@ -912,8 +930,8 @@ private void lockFields() {
912930
* tracks — i.e. all discovered fields minus those hidden via
913931
* {@link #ignoreField(HasValue)}. Visibility and enabled state are NOT
914932
* filtered, so this is the right set for the snapshot + diff used by
915-
* {@link #addFieldValueChangedListener}: a field hidden at turn start may
916-
* be revealed during the turn, and a value cascaded into it should compare
933+
* {@link #addFieldValueChangeListener}: a field hidden at turn start may be
934+
* revealed during the turn, and a value cascaded into it should compare
917935
* against its real pre-turn value rather than {@code null}.
918936
*/
919937
private List<HasValue<?, ?>> collectKnownFields() {

0 commit comments

Comments
 (0)