Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## Unreleased

* `setOptions(List<V>)` 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.
Expand Down
8 changes: 8 additions & 0 deletions docs/faq.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

</Accordion>
<Accordion title="My select's options depend on another field.">

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).

</Accordion>
<Accordion title="DropdownButtonFormField complains about value / initialValue.">

Expand Down
27 changes: 23 additions & 4 deletions docs/fields/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ AI-Provenance:
| --- | --- | --- | --- |
| `AdvancedTextFieldController<E>` | `String` | `{initialValue = '', validator, asyncValidation, focusNode, name}` | typing into `textController`, `setValue` |
| `AdvancedBooleanFieldController<E>` | `bool` | `{initialValue = false, validator, asyncValidation, focusNode, name}` | `setValue` |
| `AdvancedSingleSelectFieldController<V, E>` | `V?` | `{required V? initialValue, required List<V> options, validator, asyncValidation, focusNode, name}` | `select(V? option)`; `null` clears |
| `AdvancedMultiSelectFieldController<V, E>` | `Set<V>` | `{required Set<V> initialValue, required List<V> options, validator, asyncValidation, focusNode, name}` | `toggleElement`, `addValue`, `removeValue` |
| `AdvancedSingleSelectFieldController<V, E>` | `V?` | `{required V? initialValue, required List<V> options, validator, asyncValidation, focusNode, name}` | `select(V? option)`; `null` clears; `setOptions(List<V>)` |
| `AdvancedMultiSelectFieldController<V, E>` | `Set<V>` | `{required Set<V> initialValue, required List<V> options, validator, asyncValidation, focusNode, name}` | `toggleElement`, `addValue`, `removeValue`, `setOptions(List<V>)` |

All of them support `reset()`, `markReadOnly()` / `unmarkReadOnly()`, `setError()`, `clearErrors()`,
`setValidationMode()`, `subscribeToFields()`, `validate()`, `getValueSetter()`, `focus()`, and both sync and async
Expand Down Expand Up @@ -133,11 +133,30 @@ Holds a `V?` and a non-nullable `List<V> 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));
});
```

<Callout type="warn" title="Membership is ==">
`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.
</Callout>

## Multi select

Holds a `Set<V>`. `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

Expand Down
4 changes: 3 additions & 1 deletion docs/rendering/select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ class _SelectInputDemoState extends State<SelectInputDemo> {

`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).

<Callout type="info" title="DropdownButtonFormField and Flutter versions">
Its selected-value parameter is `value:` up to Flutter 3.33 and `initialValue:` from 3.35 on. `DropdownButton` inside
Expand Down
38 changes: 35 additions & 3 deletions lib/src/field/advanced_multi_select_field_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,43 @@ class AdvancedMultiSelectFieldController<V, E extends Object>
super.focusNode,
required List<V> options,
super.name,
}) : options = List.of(options),
}) : _options = List.unmodifiable(options),
super(initialValue: Set.of(initialValue));

/// List of options to select from.
final List<V> options;
List<V> _options;

/// The options to select from. The field keeps its own copy; replace it
/// with [setOptions].
List<V> 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<V> 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) {
Expand Down
41 changes: 37 additions & 4 deletions lib/src/field/advanced_single_select_field_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,45 @@ class AdvancedSingleSelectFieldController<V, E extends Object>
super.validator,
super.asyncValidation,
super.focusNode,
required this.options,
required List<V> options,
super.name,
});
}) : _options = List.unmodifiable(options);

/// List of options to select from.
final List<V> options;
List<V> _options;

/// The options to select from. The field keeps its own copy; replace it
/// with [setOptions].
List<V> 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<V> 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].
///
Expand Down
15 changes: 11 additions & 4 deletions skills/advanced_forms/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ Two rules prevent most bugs:
| --- | --- | --- |
| `AdvancedTextFieldController<E>` | `String` | `{initialValue = '', validator, asyncValidation, focusNode, name}`; owns `textController` |
| `AdvancedBooleanFieldController<E>` | `bool` | `{initialValue = false, validator, asyncValidation, focusNode, name}` |
| `AdvancedSingleSelectFieldController<V, E>` | `V?` | `{required V? initialValue, required List<V> options, validator, asyncValidation, focusNode, name}`; set with `select(V?)`, `null` clears |
| `AdvancedMultiSelectFieldController<V, E>` | `Set<V>` | `{required Set<V> initialValue, required List<V> options, validator, asyncValidation, focusNode, name}`; set with `toggleElement` / `addValue` / `removeValue` |
| `AdvancedSingleSelectFieldController<V, E>` | `V?` | `{required V? initialValue, required List<V> options, validator, asyncValidation, focusNode, name}`; set with `select(V?)`, `null` clears; `setOptions(List<V>)` swaps the list |
| `AdvancedMultiSelectFieldController<V, E>` | `Set<V>` | `{required Set<V> initialValue, required List<V> options, validator, asyncValidation, focusNode, name}`; set with `toggleElement` / `addValue` / `removeValue`; `setOptions(List<V>)` swaps the list |

All support `reset()`, `prefill()`, `markReadOnly()` / `unmarkReadOnly()`, `setError()`,
`clearErrors()`, `setValidationMode()`, `validate()`, `subscribeToFields()`,
Expand All @@ -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 <Topping>{}`.
- 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 <Topping>{}`.
- **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<MyError>()`.
- `AdvancedFieldController<T, E>` is **concrete** — construct it directly for any value with no
Expand Down
155 changes: 155 additions & 0 deletions test/src/field/select_field_controller_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <String?>[];
final untouched = AdvancedSingleSelectFieldController<String, _Error>(
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<String, _Error>(
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<String, _Error>(
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<String, _Error>(
initialValue: null,
options: callersOptions,
);
addTearDown(field.dispose);

callersOptions.add('b');

expect(field.options, ['a']);
});
});

group('AdvancedMultiSelectFieldController', () {
Expand Down Expand Up @@ -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 = <Set<String>>[];
final untouched = AdvancedMultiSelectFieldController<String, _Error>(
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<String, _Error>(
initialValue: const <String>{},
options: _options,
)..dispose();

expect(() => disposed.setOptions(['a']), throwsStateError);
});
});
});
}
Loading