Skip to content
Closed
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
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. options is final.">

By design: the built-in select is a fixed list of choices. For a list that changes while the form is open, extend
`AdvancedFieldController<V?, E>` yourself with a mutable list and a `setOptions` that clears a value no longer on it.
It is about thirty lines and is written out in
[A select whose options change](./fields/custom.mdx#a-select-whose-options-change).

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

Expand Down
66 changes: 66 additions & 0 deletions docs/fields/custom.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,72 @@ class PhoneFieldController extends AdvancedTextFieldController<MyError> {
Normalise `initialValue` before it reaches `super`, as above: `reset()` returns to *that* value without passing
through `setValue`.

## A select whose options change

`AdvancedSingleSelectFieldController` carries its `options` as a `final` list: the choices are fixed for the life of
the field. When they are not — instructors filtered by the chosen aircraft, cities loaded once the country is known —
build the select yourself on the concrete base class. It is a short class, and it keeps the value and the list in one
place:

```dart
class DependentSelectController<V, E extends Object>
extends AdvancedFieldController<V?, E> {
DependentSelectController({
required super.initialValue,
required List<V> options,
super.validator,
super.asyncValidation,
super.focusNode,
super.name,
}) : _options = List.unmodifiable(options);

List<V> _options;

/// The options to show right now. Replaced as a whole by [setOptions].
List<V> get options => _options;

/// Swaps the list. A selected value that is no longer on it is cleared
/// with `prefill`, so this counts as the program's doing, not the user's.
void setOptions(List<V> options) {
_options = List.unmodifiable(options);
final current = fieldValue;
if (current != null && !_options.contains(current)) {
prefill(null);
}
revalidateSync(); // "required" shows up at once — if the user had edited the field
notifyListeners(); // the options are not part of the state, so rebuild by hand
}

void select(V? option) {
assert(
option == null || _options.contains(option),
'Option $option is not one of the options of this field.',
);
setValue(option);
}
}
```

`setOptions` clears with `prefill`, not `setValue`, so a field the user never touched stays untouched and shows no
error until `validate()`, as the [validation model](../validation/modes.mdx) has it. `revalidateSync()` obeys the same
gate: on a field the user *had* picked from, the `notNull` rule runs right away and "required" appears the moment the
old choice vanished. The `notifyListeners()` call is what makes `AdvancedFieldBuilder` rebuild when only the list
changed: the options are not part of `AdvancedFieldState`, and a state that did not change does not notify.

Wire the list change to whatever drives it — a subscription on the other field or the completion of a request:

```dart
aircraft.addListener(() {
instructor.setOptions(instructorsFor(aircraft.fieldValue));
});
```

<Callout type="warn" title="Membership is ==">
`contains` compares with `==`. A `Pilot` deserialized from the server is a different object from the `Pilot` you
put on the list, so with the default identity `==` the value is cleared every time the list is refreshed. 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>

## A list as the error type

The example app's
Expand Down
3 changes: 2 additions & 1 deletion docs/fields/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ Defaults to `false`. The one rule a boolean usually needs is built in: `validato
Holds a `V?` and a non-nullable `List<V> options`. A required dropdown is `initialValue: null` plus
`validator: notNull(MyError.required)`. `select(option)` asserts in debug builds that the option is one of `options`;
`select(null)` clears and is always allowed. `initialValue` is never checked, so an off-list initial value is the way
to represent "unknown".
to represent "unknown". The list is `final`; for options that change while the form is open, see
[A select whose options change](./custom.mdx#a-select-whose-options-change).

## Multi select

Expand Down
54 changes: 54 additions & 0 deletions skills/advanced_forms/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ getters `fieldValue`, `error`, `name`, `lastFailure`, `isDisposed`.
`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>{}`.
- `options` is **final** on both selects: the choices are fixed for the life of the field. For a
list that depends on another field or on a request, do not fight the built-in select — extend
`AdvancedFieldController<V?, E>` with a mutable list (below, "Select with changing options").
Membership is `==`: a server copy of an option is not the option on the list.
- 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 Expand Up @@ -173,6 +177,56 @@ class PhoneFieldController<E extends Object>
}
```

**Select with changing options.** The built-in select's `options` is `final`. When the list
depends on another field (instructors for the chosen aircraft) or arrives from the server, build
the select on the concrete base class — keep the package's model, add the one mutable list:

```dart
class DependentSelectController<V, E extends Object>
extends AdvancedFieldController<V?, E> {
DependentSelectController({
required super.initialValue,
required List<V> options,
super.validator,
super.asyncValidation,
super.focusNode,
super.name,
}) : _options = List.unmodifiable(options);

List<V> _options;

/// The options to show right now. Replaced as a whole by [setOptions].
List<V> get options => _options;

/// Swaps the list. A selected value that is no longer on it is cleared
/// with `prefill`, so this counts as the program's doing, not the user's.
void setOptions(List<V> options) {
_options = List.unmodifiable(options);
final current = fieldValue;
if (current != null && !_options.contains(current)) {
prefill(null);
}
revalidateSync(); // "required" shows up at once — if the user had edited the field
notifyListeners(); // the options are not part of the state, so rebuild by hand
}

void select(V? option) {
assert(
option == null || _options.contains(option),
'Option $option is not one of the options of this field.',
);
setValue(option);
}
}
```

`prefill`, not `setValue`, does the clearing: an untouched field stays untouched (rule 2 below).
`revalidateSync()` obeys the gate, so a field the user *had* picked from shows "required" at once.
`notifyListeners()` is needed because options are not in `AdvancedFieldState`, so a list change
alone rebuilds nothing. Drive it with `aircraft.addListener(() => instructor.setOptions(...))`.
Bind with `AdvancedFieldBuilder<V?, E>` and a `DropdownButton` over `field.options`, as for the
built-in select.

Every write to `textController` — keystroke, paste, programmatic `.text =` — goes through the
public `setValue`, so the override always runs and the transformed value is written back with
the caret kept on the same characters. Three writes **bypass** `setValue` and need the same
Expand Down
Loading