From 6a6b7b91778e9533fe45f7dbf0af7014d2b9ee14 Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sun, 20 Sep 2026 21:44:49 +0200 Subject: [PATCH] Select options can change while the form is open Both select controllers get setOptions(List). A selected value that is not on the new list is cleared as a program write, like prefill, so an untouched field stays quiet; widgets rebuild even when the value stayed. The single select now copies the list it is given, as the multi select always did. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 5 + docs/faq.mdx | 8 + docs/fields/index.mdx | 27 ++- docs/rendering/select.mdx | 4 +- ...dvanced_multi_select_field_controller.dart | 38 ++++- ...vanced_single_select_field_controller.dart | 41 ++++- skills/advanced_forms/SKILL.md | 15 +- .../field/select_field_controller_test.dart | 155 ++++++++++++++++++ 8 files changed, 277 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fc5a88..d3b7f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +* `setOptions(List)` on `AdvancedSingleSelectFieldController` and `AdvancedMultiSelectFieldController`: the list of options can change while the form is open. A selected value that is not on the new list is cleared as a program write (no user edit, no error on an untouched field); widgets rebuild even when the value stayed the same. +* The single-select controller now copies the `options` you pass, as the multi-select always did. `options` is an unmodifiable list on both. + ## 0.2.1+1 * Documentation only, no library changes. diff --git a/docs/faq.mdx b/docs/faq.mdx index 8de0068..b81cc04 100644 --- a/docs/faq.mdx +++ b/docs/faq.mdx @@ -89,6 +89,14 @@ over `String?` like the built-ins. See [Validators](./validation/validators.mdx# `mustBeTrue(MyError.mustAccept)` covers the checkbox that has to be ticked. There is no `Set` validator, and `notEmpty` is for `List`, so a rule on a multi-select is a one-line closure: `(chosen) => chosen.isEmpty ? MyError.pickOne : null`. + + + +Call `setOptions(newList)` on the select when the other field changes, for instance from a listener on it. A selected +value that is not on the new list is cleared without counting as a user edit, and the dropdown rebuilds. Watch out for +`==`: an option deserialized from the server is not the option on your list unless `V` is a value type. See +[Single select](./fields/index.mdx#single-select). + diff --git a/docs/fields/index.mdx b/docs/fields/index.mdx index b49b063..e3f4c5e 100644 --- a/docs/fields/index.mdx +++ b/docs/fields/index.mdx @@ -13,8 +13,8 @@ AI-Provenance: | --- | --- | --- | --- | | `AdvancedTextFieldController` | `String` | `{initialValue = '', validator, asyncValidation, focusNode, name}` | typing into `textController`, `setValue` | | `AdvancedBooleanFieldController` | `bool` | `{initialValue = false, validator, asyncValidation, focusNode, name}` | `setValue` | -| `AdvancedSingleSelectFieldController` | `V?` | `{required V? initialValue, required List options, validator, asyncValidation, focusNode, name}` | `select(V? option)`; `null` clears | -| `AdvancedMultiSelectFieldController` | `Set` | `{required Set initialValue, required List options, validator, asyncValidation, focusNode, name}` | `toggleElement`, `addValue`, `removeValue` | +| `AdvancedSingleSelectFieldController` | `V?` | `{required V? initialValue, required List options, validator, asyncValidation, focusNode, name}` | `select(V? option)`; `null` clears; `setOptions(List)` | +| `AdvancedMultiSelectFieldController` | `Set` | `{required Set initialValue, required List options, validator, asyncValidation, focusNode, name}` | `toggleElement`, `addValue`, `removeValue`, `setOptions(List)` | All of them support `reset()`, `markReadOnly()` / `unmarkReadOnly()`, `setError()`, `clearErrors()`, `setValidationMode()`, `subscribeToFields()`, `validate()`, `getValueSetter()`, `focus()`, and both sync and async @@ -133,11 +133,30 @@ Holds a `V?` and a non-nullable `List options`. A required dropdown is `initi `select(null)` clears and is always allowed. `initialValue` is never checked, so an off-list initial value is the way to represent "unknown". +The list can change while the form is open — instructors filtered by the chosen aircraft, cities loaded once the +country is known. `setOptions(newList)` replaces it and clears a selected value that is not on the new list. The +clearing is the program's doing, like `prefill`: an untouched field stays untouched and shows no error until +`validate()`, while a field the user had picked from shows its `notNull` error at once. A read-only field is cleared +too, since a value that is not on the list cannot be shown. Widgets rebuild even when the value stayed the same. + +```dart +aircraft.addListener(() { + instructor.setOptions(instructorsFor(aircraft.fieldValue)); +}); +``` + + + `select`, `addValue` and `setOptions` compare with `==`. A `Pilot` deserialized from the server is a different object + from the `Pilot` on the list, so with the default identity `==` every refresh clears the selection. Make `V` a value + type (`Equatable`, a record, an enum) or select by id and resolve to the instance that is on the list. + + ## Multi select Holds a `Set`. `toggleElement(v)` adds or removes; `addValue` asserts membership in `options`, `removeValue` stays -silent for an off-list value. The controller copies the `initialValue` set and the `options` list, so mutating what you -passed in never reaches the field. +silent for an off-list value. `setOptions(newList)` replaces the list and drops the selected values that are not on it, +with the same rules as on the single select. Both controllers copy the `initialValue` and the `options` you pass, so +mutating them later never reaches the field. ## Reading a field diff --git a/docs/rendering/select.mdx b/docs/rendering/select.mdx index f90bd43..4af3bdf 100644 --- a/docs/rendering/select.mdx +++ b/docs/rendering/select.mdx @@ -154,7 +154,9 @@ class _SelectInputDemoState extends State { `field.select(option)` and `field.toggleElement(option)` are the setters; both are no-ops while read-only, which is why the widgets null their callbacks on `state.readOnly` rather than calling `getValueSetter()`. `select` asserts in debug -builds that the option is one of `options`; `select(null)` clears and is always allowed. +builds that the option is one of `options`; `select(null)` clears and is always allowed. When the list itself +changes — say, plans available in the chosen country — `setOptions(newList)` replaces it, clears a value that is no +longer on it and rebuilds the dropdown; see [Single select](../fields/index.mdx#single-select). Its selected-value parameter is `value:` up to Flutter 3.33 and `initialValue:` from 3.35 on. `DropdownButton` inside diff --git a/lib/src/field/advanced_multi_select_field_controller.dart b/lib/src/field/advanced_multi_select_field_controller.dart index 80900f2..f94cfcc 100644 --- a/lib/src/field/advanced_multi_select_field_controller.dart +++ b/lib/src/field/advanced_multi_select_field_controller.dart @@ -11,11 +11,43 @@ class AdvancedMultiSelectFieldController super.focusNode, required List options, super.name, - }) : options = List.of(options), + }) : _options = List.unmodifiable(options), super(initialValue: Set.of(initialValue)); - /// List of options to select from. - final List options; + List _options; + + /// The options to select from. The field keeps its own copy; replace it + /// with [setOptions]. + List get options => _options; + + /// Replaces [options], for a list that depends on another field or arrives + /// from the server after the field was created. + /// + /// Selected values that are not on the new list are dropped, on behalf of + /// the program: like [prefill], this does not count as the user having + /// edited the field, so an untouched field stays untouched and shows no + /// error until [validate]. A read-only field is pruned too, because a + /// value that is not on the list cannot be shown. On a field the user has + /// edited, the sync validator runs again under the field's mode. + /// + /// Listeners are notified even when the value did not change, so a widget + /// listing [options] rebuilds. + /// + /// Throws a [StateError] if this field has already been disposed. + void setOptions(List options) { + if (isDisposed) { + throw StateError( + 'Cannot set options on a disposed AdvancedMultiSelectFieldController.', + ); + } + _options = List.unmodifiable(options); + final kept = fieldValue.where(_options.contains).toSet(); + if (kept.length != fieldValue.length) { + prefill(kept, force: true); + } + revalidateSync(); + notifyListeners(); + } /// Toggles the given [value]. void toggleElement(V value) { diff --git a/lib/src/field/advanced_single_select_field_controller.dart b/lib/src/field/advanced_single_select_field_controller.dart index 6071ddd..ed6c142 100644 --- a/lib/src/field/advanced_single_select_field_controller.dart +++ b/lib/src/field/advanced_single_select_field_controller.dart @@ -10,12 +10,45 @@ class AdvancedSingleSelectFieldController super.validator, super.asyncValidation, super.focusNode, - required this.options, + required List options, super.name, - }); + }) : _options = List.unmodifiable(options); - /// List of options to select from. - final List options; + List _options; + + /// The options to select from. The field keeps its own copy; replace it + /// with [setOptions]. + List get options => _options; + + /// Replaces [options], for a list that depends on another field or arrives + /// from the server after the field was created. + /// + /// A selected value that is not on the new list is cleared, on behalf of + /// the program: like [prefill], this does not count as the user having + /// edited the field, so an untouched field stays untouched and shows no + /// error until [validate]. A read-only field is cleared too, because a + /// value that is not on the list cannot be shown. On a field the user has + /// edited, the sync validator runs again under the field's mode, so a + /// `notNull` rule reports the vanished choice at once. + /// + /// Listeners are notified even when the value did not change, so a widget + /// listing [options] rebuilds. + /// + /// Throws a [StateError] if this field has already been disposed. + void setOptions(List options) { + if (isDisposed) { + throw StateError( + 'Cannot set options on a disposed AdvancedSingleSelectFieldController.', + ); + } + _options = List.unmodifiable(options); + final current = fieldValue; + if (current != null && !_options.contains(current)) { + prefill(null, force: true); + } + revalidateSync(); + notifyListeners(); + } /// Sets the value of the field to the [option]. /// diff --git a/skills/advanced_forms/SKILL.md b/skills/advanced_forms/SKILL.md index d8befb5..9015e1e 100644 --- a/skills/advanced_forms/SKILL.md +++ b/skills/advanced_forms/SKILL.md @@ -118,8 +118,8 @@ Two rules prevent most bugs: | --- | --- | --- | | `AdvancedTextFieldController` | `String` | `{initialValue = '', validator, asyncValidation, focusNode, name}`; owns `textController` | | `AdvancedBooleanFieldController` | `bool` | `{initialValue = false, validator, asyncValidation, focusNode, name}` | -| `AdvancedSingleSelectFieldController` | `V?` | `{required V? initialValue, required List options, validator, asyncValidation, focusNode, name}`; set with `select(V?)`, `null` clears | -| `AdvancedMultiSelectFieldController` | `Set` | `{required Set initialValue, required List options, validator, asyncValidation, focusNode, name}`; set with `toggleElement` / `addValue` / `removeValue` | +| `AdvancedSingleSelectFieldController` | `V?` | `{required V? initialValue, required List options, validator, asyncValidation, focusNode, name}`; set with `select(V?)`, `null` clears; `setOptions(List)` swaps the list | +| `AdvancedMultiSelectFieldController` | `Set` | `{required Set initialValue, required List options, validator, asyncValidation, focusNode, name}`; set with `toggleElement` / `addValue` / `removeValue`; `setOptions(List)` swaps the list | All support `reset()`, `prefill()`, `markReadOnly()` / `unmarkReadOnly()`, `setError()`, `clearErrors()`, `setValidationMode()`, `validate()`, `subscribeToFields()`, @@ -140,8 +140,15 @@ getters `fieldValue`, `error`, `name`, `lastFailure`, `isDisposed`. `initialValue: null` plus `validator: notNull(MyError.required)`. - `select` and `addValue` **assert** the argument is one of `options` — and so does `toggleElement` when it adds. `prefill` does not assert, so check server-supplied values. -- The multi-select copies the set and list you pass, so mutating them later never reaches the - field. `const {}` is a **Map**: an empty initial selection is `const {}`. +- Both selects copy the `options` (and the multi-select the set) you pass, so mutating them later + never reaches the field. `const {}` is a **Map**: an empty initial selection is `const {}`. +- **Options that depend on another field or on a request:** `field.setOptions(newList)`, e.g. from + `aircraft.addListener(() => instructor.setOptions(instructorsFor(aircraft.fieldValue)))`. A + selected value not on the new list is cleared (dropped, on the multi-select) as a *program* + write, like `prefill`: an untouched field stays untouched, an edited one shows its `notNull` + error at once; read-only fields are cleared too. Widgets rebuild even if the value stayed. + Membership is `==` — a server copy of an option is not the option on the list, so use value + types or resolve by id. - With no `validator`, `E` infers to its bound `Object`. That compiles and then breaks every `switch` on your error enum, so spell it out: `AdvancedTextFieldController()`. - `AdvancedFieldController` is **concrete** — construct it directly for any value with no diff --git a/test/src/field/select_field_controller_test.dart b/test/src/field/select_field_controller_test.dart index e45c4ea..8abf0c5 100644 --- a/test/src/field/select_field_controller_test.dart +++ b/test/src/field/select_field_controller_test.dart @@ -101,6 +101,102 @@ void main() { expect(checked, ['a', 'b']); expect(asyncField.error, _Error.taken); }); + + group('setOptions', () { + test('replaces the list and notifies with the value unchanged', () { + field.select('b'); + final notifications = _countCalls(field); + + field.setOptions(['b', 'x']); + + expect(field.options, ['b', 'x']); + expect(field.fieldValue, 'b'); + expect(notifications(), 1); + }); + + test('clears a value that is not on the new list', () { + field + ..select('b') + ..setOptions(['a', 'c']); + + expect(field.fieldValue, null); + }); + + test('clearing does not count as a user edit', () { + final validated = []; + final untouched = AdvancedSingleSelectFieldController( + initialValue: 'b', + options: _options, + validator: (value) { + validated.add(value); + return value == null ? _Error.valueRequired : null; + }, + )..setValidationMode(ValidationMode.onUserInteraction); + addTearDown(untouched.dispose); + + untouched.setOptions(['a']); + + expect(untouched.fieldValue, null); + expect(untouched.error, null); + expect(validated, isEmpty); + }); + + test('on an edited field the sync validator reports the vanished choice', + () { + final edited = AdvancedSingleSelectFieldController( + initialValue: null, + options: _options, + validator: (value) => value == null ? _Error.valueRequired : null, + )..setValidationMode(ValidationMode.onUserInteraction); + addTearDown(edited.dispose); + + edited + ..select('b') + ..setOptions(['a']); + + expect(edited.error, _Error.valueRequired); + }); + + test('clears a read-only field too', () { + field + ..select('b') + ..markReadOnly() + ..setOptions(['a']); + + expect(field.fieldValue, null); + }); + + test('the new list is a copy', () { + final callersOptions = ['a']; + field.setOptions(callersOptions); + + callersOptions.add('b'); + + expect(field.options, ['a']); + }); + + test('throws StateError when the field has been disposed', () { + final disposed = AdvancedSingleSelectFieldController( + initialValue: null, + options: _options, + )..dispose(); + + expect(() => disposed.setOptions(['a']), throwsStateError); + }); + }); + + test("mutating the caller's options does not reach the field", () { + final callersOptions = ['a']; + final field = AdvancedSingleSelectFieldController( + initialValue: null, + options: callersOptions, + ); + addTearDown(field.dispose); + + callersOptions.add('b'); + + expect(field.options, ['a']); + }); }); group('AdvancedMultiSelectFieldController', () { @@ -232,5 +328,64 @@ void main() { expect(field.options, ['a']); }); + + group('setOptions', () { + test('replaces the list and notifies with the value unchanged', () { + field.addValue('a'); + final notifications = _countCalls(field); + + field.setOptions(['a', 'x']); + + expect(field.options, ['a', 'x']); + expect(field.fieldValue, {'a'}); + expect(notifications(), 1); + }); + + test('drops the selected values that are not on the new list', () { + field + ..addValue('a') + ..addValue('b') + ..setOptions(['b', 'c']); + + expect(field.fieldValue, {'b'}); + }); + + test('dropping does not count as a user edit', () { + final validated = >[]; + final untouched = AdvancedMultiSelectFieldController( + initialValue: const {'a'}, + options: _options, + validator: (value) { + validated.add(value); + return value.isEmpty ? _Error.valueRequired : null; + }, + )..setValidationMode(ValidationMode.onUserInteraction); + addTearDown(untouched.dispose); + + untouched.setOptions(['b']); + + expect(untouched.fieldValue, isEmpty); + expect(untouched.error, null); + expect(validated, isEmpty); + }); + + test('prunes a read-only field too', () { + field + ..addValue('a') + ..markReadOnly() + ..setOptions(['b']); + + expect(field.fieldValue, isEmpty); + }); + + test('throws StateError when the field has been disposed', () { + final disposed = AdvancedMultiSelectFieldController( + initialValue: const {}, + options: _options, + )..dispose(); + + expect(() => disposed.setOptions(['a']), throwsStateError); + }); + }); }); }