diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cae02e..f91b103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * `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. * The Agent Skill is a [package skill](https://dart.dev/tools/pub/package-skills): `dart run skills@ get` installs it into your agent's skills folder. The folder moved from `skills/advanced_forms/` to `skills/advanced_forms-build-forms/`, the name the CLI requires; the skill now adds the package with `flutter pub add advanced_forms` when the project lacks it. +* Added `enabled` to `addSubform`: the section is attached only while the closure returns true, and keeps its values when it is not. ## 0.2.1+1 diff --git a/docs/subforms/index.mdx b/docs/subforms/index.mdx index 420ad17..c20c411 100644 --- a/docs/subforms/index.mdx +++ b/docs/subforms/index.mdx @@ -185,6 +185,9 @@ class _GuestCard extends StatelessWidget { - **`addSubform(form)`** attaches. A no-op if already attached. The parent applies its validation mode to the subform at once — unless the subform has a mode of its own. +- **`addSubform(form, enabled: () => …)`** attaches a conditional section: the closure decides, now and after every + value change in the form, whether the section is attached. Detached, it keeps its values and its owner. See + [Patterns](./patterns.mdx#a-section-behind-a-switch). - **`removeSubform(form)`** detaches: the subform stops validating, notifying and counting towards the parent's state, and can be re-attached later. It does **not** dispose it. - **The parent owns every subform it was ever given** and disposes them all in its own `dispose()`, attached or diff --git a/docs/subforms/patterns.mdx b/docs/subforms/patterns.mdx index 1941aa9..41ab710 100644 --- a/docs/subforms/patterns.mdx +++ b/docs/subforms/patterns.mdx @@ -9,28 +9,54 @@ AI-Provenance: harness: Claude Code */} -## Detach, or switch validation off? +## A section behind a switch -A section that is *visible but not applicable* — invoice details behind an unchecked "I need an invoice" switch — is -better kept attached with `setValidationEnabled(false)`: - -- its errors are cleared and its fields leave `canSubmit`, `validating`, `hasFailedValidation` and `validationErrors`; -- its `validate()` returns `true` without running; -- everything else — `resetAll`, `markReadOnly`, `clearErrors`, disposal — still reaches it; -- switching back on re-runs the sync validators at once, and the values are still there. - -Detach with `removeSubform` when the section genuinely leaves the screen and its values should not take part in -anything. A detached subform is still owned — and disposed — by the parent, and can be re-attached later. +Invoice details behind an "I need an invoice" checkbox, a shipping address unless "same as billing" is on: hand the +condition to `addSubform`. ```dart CheckoutFormController() { registerFields([email, needsInvoice]); - addSubform(invoice); - addRelation(needsInvoice, (on) => on, invoice.setValidationEnabled); - invoice.setValidationEnabled(needsInvoice.fieldValue); // relations fire on change only + addSubform(invoice, enabled: () => needsInvoice.fieldValue); } ``` +The closure is evaluated at `addSubform` and again whenever a value anywhere in the form changes. While it is true the +section is attached; when it turns false the section is detached, exactly as `removeSubform` would: its fields leave +`validate()`, `canSubmit`, `getFieldValues()` and `allFields`, so the payload you build from the form has no invoice +in it. The form owns the section either way, so **the values stay in its fields** and are there again when the +checkbox goes back on — and the section is disposed with the form. + +The closure can be any boolean expression over the fields' values. Because the form re-evaluates it after `resetAll()` +too, a reset checkbox takes the section with it. +A condition that depends on something *outside* the form — a service, a route argument — is not re-evaluated for that; +attach and detach by hand then. + +**Which to use.** `enabled:` when the decision is a function of the form's own values; `addSubform` and +`removeSubform` by hand when it comes from outside the form. The two do not fight, the last call by hand wins: a manual +`removeSubform` detaches the section *and drops the condition*, so the section stays out until the next `addSubform`; +a plain `addSubform` attaches it for good; `addSubform` with a new `enabled:` replaces the condition. + +## Or keep it attached and switch validation off + +Detached, the section is out of reach of `resetAll`, `markReadOnly`, `clearErrors` and `setValidationMode`, and its +values are not part of the form's. When you want those to keep reaching a section that merely does not apply right +now, keep it attached and use `setValidationEnabled(false)` instead: + +- its errors are cleared and its fields leave `canSubmit`, `validating`, `hasFailedValidation` and `validationErrors`; +- its `validate()` returns `true` without running; +- everything else — `resetAll`, `markReadOnly`, `clearErrors`, disposal — still reaches it, and its values stay in + `getFieldValues()`; +- switching back on re-runs the sync validators at once. + +```dart +addSubform(invoice); +addRelation(needsInvoice, (on) => on, invoice.setValidationEnabled); +invoice.setValidationEnabled(needsInvoice.fieldValue); // relations fire on change only +``` + +`resetAll()` does not touch `validationEnabled`, so seed it again wherever you reset. + ## Swapping one section for another A type selector that swaps the active section — a *person* or a *company* — is `removeSubform` of one and diff --git a/docs/subforms/wizard.mdx b/docs/subforms/wizard.mdx index ddd8e52..f704a5e 100644 --- a/docs/subforms/wizard.mdx +++ b/docs/subforms/wizard.mdx @@ -239,7 +239,7 @@ Three decisions in that code are worth keeping: - **Escalate the failed step, not the whole form.** After a failed `next()`, the step gets `ValidationMode.onUserInteraction` so it corrects itself as the user types. Because a subform's own mode wins over the parent's, the steps ahead stay in `manual` and never flag an untouched field. -- **A conditional step stays attached with `setValidationEnabled(false)`.** The example app's *Step Form* screen has an - invoice step that a switch on the address step adds or drops through `addRelation`. Switched off, the step leaves - the flow *and* `validate()`, `canSubmit` and `validating`, so one flag is the whole condition — and its values survive - in case the user flips the switch back. +- **A conditional step comes and goes with its switch.** The example app's *Step Form* screen attaches its invoice step + with `addSubform(invoice, enabled: () => address.needsInvoice.fieldValue)`. Detached, the step leaves the flow *and* + `validate()`, `canSubmit` and `validating`, so membership is the whole condition — and the wizard still owns it, so + its values survive in case the user flips the switch back. diff --git a/docs/validation/relations.mdx b/docs/validation/relations.mdx index cd86e68..99c19ed 100644 --- a/docs/validation/relations.mdx +++ b/docs/validation/relations.mdx @@ -112,7 +112,8 @@ cannot loop. Three details matter in practice: country changes and nothing else can wipe the user's choice; the [wizard](../subforms/wizard.mdx) does exactly that. Attaching and detaching subforms, or calling `setValidationEnabled`, from `onChange` is supported too — that is how a -switch on one page adds or drops another page of a wizard. +type selector swaps one section for another. A section that only comes and goes with a switch does not need a relation +at all: `addSubform(section, enabled: () => …)` keeps the condition itself, see [Patterns](../subforms/patterns.mdx). ## Which one do I want? @@ -123,4 +124,5 @@ switch on one page adds or drops another page of a wizard. | re-check every rule because something *outside* the form changed | `form.revalidateSync()` | | set B's value when A changes | `addRelation(A, select, (a) => B.setValue(…))` | | forget B's async verdict when A changes | `addRelation(A, select, (_) => B.clearErrors())` | +| attach and detach a section with A | `addSubform(section, enabled: () => A.fieldValue …)` | | react to A outside the form (analytics, a service) | `A.addListener(…)`, comparing values yourself | diff --git a/example/lib/screens/step_form.dart b/example/lib/screens/step_form.dart index 755c1a7..2260f41 100644 --- a/example/lib/screens/step_form.dart +++ b/example/lib/screens/step_form.dart @@ -5,6 +5,7 @@ import 'package:advanced_forms_example/widgets/form_dropdown_field.dart'; import 'package:advanced_forms_example/widgets/form_switch_field.dart'; import 'package:advanced_forms_example/widgets/form_text_field.dart'; import 'package:advanced_forms_example/widgets/screen_description.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -17,8 +18,9 @@ import 'package:provider/provider.dart'; /// would dispose it. /// * **Next** validates the current page's subform only. /// * **Submit** validates the parent, which reaches every attached subform. -/// * A **skipped page** stays attached with `setValidationEnabled(false)`: the -/// navigation walks past it and its fields stop counting towards the parent. +/// * A **skipped page** is detached through `addSubform`'s `enabled:`: the +/// navigation walks past it and its fields stop counting towards the parent, +/// while the wizard still owns it and its values. class StepFormScreen extends StatelessWidget { const StepFormScreen({super.key}); @@ -92,9 +94,11 @@ class _StepFormState extends State { code('john@email.com'), plain('. The '), bold('Invoice'), - plain(' page is conditional: with the switch on Address off, '), - code('setValidationEnabled(false)'), - plain(' takes it out of the flow and out of '), + plain(' page is conditional: attached with '), + code('enabled: () => needsInvoice.fieldValue'), + plain( + ', so with the switch on Address off it leaves the flow and ', + ), code('validate()'), plain('.'), ]), @@ -278,7 +282,7 @@ class _AddressStep extends StatelessWidget { hintText: 'Enter your city', ), // The decision that adds or drops the Invoice page — an ordinary field - // the wizard watches with `addRelation`. + // the wizard's `addSubform(invoice, enabled: …)` reads. FormSwitchField( field: controller.needsInvoice, labelText: 'I need a VAT invoice', @@ -363,14 +367,18 @@ abstract class WizardStepController extends AdvancedFormController { /// fields with `notifyListeners()`; only [next], [back] and [submit] move it. class StepFormController extends AdvancedFormController { StepFormController() { - steps.forEach(addSubform); - - // Keeping the Invoice step attached and only switching its validation off - // is what makes the skip reversible: its values, its fields and its - // disposal stay with the wizard. `addRelation` fires on change only, so the - // initial state is seeded right after. - addRelation(address.needsInvoice, (value) => value, _setInvoiceEnabled); - _setInvoiceEnabled(address.needsInvoice.fieldValue); + addSubform(account); + addSubform(address); + // The Invoice step is conditional: attached while the switch on the + // Address step is on, detached otherwise. The wizard owns it either way, + // so its values survive a skip and it is disposed with the wizard. The + // form re-evaluates the condition itself whenever a value in it changes. + addSubform(invoice, enabled: () => address.needsInvoice.fieldValue); + addSubform(confirm); + + // Navigation follows: the flow is the steps that are attached right now. + addListener(_syncActiveSteps); + _syncActiveSteps(); } final account = AccountStepController(); @@ -380,9 +388,9 @@ class StepFormController extends AdvancedFormController { late final steps = [account, address, invoice, confirm]; - /// The steps the user actually walks through. A step switched off is out of - /// the flow *and* out of `validate()`, `canSubmit` and `validating`, so that - /// one flag is the whole condition. + /// The steps the user actually walks through. A detached step is out of + /// the flow *and* out of `validate()`, `canSubmit` and `validating`, so + /// membership is the whole condition. List get activeSteps => _activeSteps; var _activeSteps = []; @@ -454,13 +462,16 @@ class StepFormController extends AdvancedFormController { } } - void _setInvoiceEnabled(bool enabled) { - final current = _activeSteps.isEmpty ? null : currentStep; - invoice.setValidationEnabled(enabled); - _activeSteps = [ + void _syncActiveSteps() { + final active = [ for (final step in steps) - if (step.value.validationEnabled) step, + if (value.subforms.contains(step)) step, ]; + if (listEquals(active, _activeSteps)) { + return; + } + final current = _activeSteps.isEmpty ? null : currentStep; + _activeSteps = active; // The user is never standing on a step as it leaves the flow — the switch // is on an earlier one — but clamp rather than trust that. _currentIndex = _activeSteps @@ -512,8 +523,8 @@ Future _checkEmailTaken(String value) async { } /// Step 2. Picking another country clears the city, through the form's -/// `addRelation` — "when B changes, set A". The wizard reads the invoice switch -/// the same way, one level up. +/// `addRelation` — "when B changes, set A". The invoice switch needs no +/// relation: the wizard hands it to `addSubform` as the step's `enabled`. class AddressStepController extends WizardStepController { AddressStepController() { registerFields([country, city, needsInvoice]); diff --git a/lib/src/form/advanced_form_controller.dart b/lib/src/form/advanced_form_controller.dart index 85049b7..883b247 100644 --- a/lib/src/form/advanced_form_controller.dart +++ b/lib/src/form/advanced_form_controller.dart @@ -60,6 +60,9 @@ class AdvancedFormController // Explicit type — inference would widen the error type from dynamic to Object. final Set> _ownedFields = {}; final Set _ownedSubforms = {}; + // Conditional sections: the `enabled` closure and its last result. + // Re-evaluated whenever a value in this tree changes; attaches and detaches. + final Map _subformConditions = {}; final _validateCall = SharedCall(); // null: follow the parent form's mode. Non-null: this form manages its own. @@ -201,9 +204,33 @@ class AdvancedFormController /// Ownership outlives [removeSubform]: a detached subform is still disposed /// with this form, the same way a deregistered field is. /// + /// [enabled] is a shortcut over calling this and [removeSubform] yourself, + /// for a section that follows a value in the form: + /// + /// ```dart + /// addSubform(invoice, enabled: () => needsInvoice.fieldValue); + /// addSubform(company, enabled: () => type.fieldValue == CustomerType.company); + /// ``` + /// + /// It is evaluated now and again whenever a value anywhere in this form's + /// tree changes. While it is true the section is attached; when it turns + /// false the section is detached, as with [removeSubform]: it leaves + /// `validate`, `canSubmit`, `getFieldValues` and every other broadcast. This + /// form owns the section either way, so its values stay in its fields and + /// are there again when it comes back — and it is disposed with this form. + /// + /// Which to use: [enabled] when the decision is a function of the form's + /// own values; the two calls by hand when it comes from outside the form — + /// a route argument, a service — because the condition is not re-evaluated + /// for those. The two do not fight: the last call by hand wins. A manual + /// [removeSubform] detaches the section *and drops the condition*, so the + /// section stays out until the next [addSubform]; a plain [addSubform] + /// attaches it for good; [addSubform] with a new [enabled] replaces the + /// condition. + /// /// Throws a [StateError] if either controller has already been disposed — /// disposed controllers cannot be reused. - void addSubform(AdvancedFormController form) { + void addSubform(AdvancedFormController form, {bool Function()? enabled}) { if (isDisposed) { throw StateError( 'Cannot add a subform to a disposed AdvancedFormController.', @@ -214,10 +241,29 @@ class AdvancedFormController 'Cannot add a disposed AdvancedFormController as a subform.', ); } - if (value.subforms.contains(form)) { + if (enabled == null) { + if (value.subforms.contains(form)) { + return; + } + _attach(form); return; } + // A condition given again replaces the previous one. + _subformConditions.remove(form); + _ownedSubforms.add(form); + final result = enabled(); + _subformConditions[form] = (enabled: enabled, last: result); + if (result) { + if (!value.subforms.contains(form)) { + _attach(form); + } + } else if (value.subforms.contains(form)) { + _detach(form); + } + } + + void _attach(AdvancedFormController form) { _runChildCleanups(); // value.subforms is what participates; _ownedSubforms is what gets disposed. _setState(value.copyWith(subforms: {...value.subforms, form})); @@ -227,8 +273,34 @@ class AdvancedFormController _recomputeWasModified(); } + void _detach(AdvancedFormController form) { + _runChildCleanups(); + _setState(value.copyWith(subforms: {...value.subforms}..remove(form))); + _wireChildren(); + _recomputeWasModified(); + } + + @override + void _applySubformConditions() { + // A copy: the map is written while it is walked. + for (final MapEntry(key: form, value: (:enabled, :last)) + in _subformConditions.entries.toList()) { + final next = enabled(); + if (next == last) { + continue; + } + _subformConditions[form] = (enabled: enabled, last: next); + if (next) { + _attach(form); + } else { + _detach(form); + } + } + } + /// Detaches an owned subform: it stops validating, notifying and counting - /// towards this form's state. A noop if [form] was not a subform. + /// towards this form's state. A noop if [form] was not a subform. Drops the + /// `enabled` condition [form] was attached with, if any. /// /// Does not dispose [form] — this form still owns it and disposes it in /// [dispose], so a detached subform can be re-attached with [addSubform]. @@ -241,14 +313,11 @@ class AdvancedFormController 'Cannot remove a subform from a disposed AdvancedFormController.', ); } + _subformConditions.remove(form); if (!value.subforms.contains(form)) { return; } - - _runChildCleanups(); - _setState(value.copyWith(subforms: {...value.subforms}..remove(form))); - _wireChildren(); - _recomputeWasModified(); + _detach(form); } /// Turns validation for this whole subtree on or off — above every mode. @@ -274,6 +343,7 @@ class AdvancedFormController _isDisposed = true; _runChildCleanups(); _runRelationCleanups(); + _subformConditions.clear(); for (final field in _ownedFields) { field.dispose(); } @@ -358,3 +428,6 @@ class AdvancedFormController notifyListeners(); } } + +/// The `enabled` closure of a conditional subform and its last result. +typedef _SubformCondition = ({bool Function() enabled, bool last}); diff --git a/lib/src/form/child_wiring.dart b/lib/src/form/child_wiring.dart index 37fc8f4..06aea84 100644 --- a/lib/src/form/child_wiring.dart +++ b/lib/src/form/child_wiring.dart @@ -16,6 +16,9 @@ mixin _ChildWiring on ChangeNotifier { void _setState(AdvancedFormState newValue); + /// Re-evaluates the `enabled` conditions of conditional subforms. + void _applySubformConditions(); + final _onValuesChanged = ChangeNotifier(); /// Fires when any leaf field's value changes (recursively through subforms), @@ -76,6 +79,7 @@ mixin _ChildWiring on ChangeNotifier { if (validateAll) { revalidateSync(); } + _applySubformConditions(); _recomputeWasModified(); _onValuesChanged.notifyListeners(); } diff --git a/skills/advanced_forms-build-forms/SKILL.md b/skills/advanced_forms-build-forms/SKILL.md index 09ddcb0..03da8bf 100644 --- a/skills/advanced_forms-build-forms/SKILL.md +++ b/skills/advanced_forms-build-forms/SKILL.md @@ -551,7 +551,8 @@ MyForm() { `source` is already disposed. - A *destructive* relation — `city.reset()` when `country` changes — is safe: `validate()` changes no values, so it cannot wipe the user's choice. Attaching or detaching subforms and - calling `setValidationEnabled` from `onChange` is supported. + calling `setValidationEnabled` from `onChange` is supported; a section that simply comes + and goes with a switch is `addSubform(…, enabled:)` instead. - Raw `addListener` + `setValue` is right only when the target is not a form field at all — a plain `ValueNotifier`, a service callback — and there you do the `==` comparison yourself, because `addListener` fires on **any** state change. @@ -672,6 +673,7 @@ field.reset(); // back to initialValue; clears both errors, // readOnly + validationMode — those are configuration form.markReadOnly(); form.clearErrors(); form.resetAll(); // whole tree form.setValidationEnabled(false); // this subtree stops validating and stops counting +form.addSubform(section, enabled: () => flag.fieldValue); // attached while true, detached while false ``` Push server errors **after** the `await`, never before: `validate()` and anything else that @@ -738,20 +740,13 @@ data is there before construction, or the form has a discard button. Split big forms, or attach sections that appear dynamically. Subform fields join the parent's `validate`, `markReadOnly`, `resetAll`, `wasModified` and the rest — but **only while attached**. -### A section that is toggled — keep it attached, switch validation off - -Prefer this. The subform stays in the tree, so `resetAll`, `markReadOnly`, `clearErrors`, -`setValidationMode` and `dispose()` all still reach it, and no ownership changes hands. +### A section behind a switch — `addSubform(…, enabled:)` ```dart class CheckoutFormController extends AdvancedFormController { CheckoutFormController() { registerFields([email, sameAsBilling]); - addSubform(shipping); - // addRelation fires on change only, so seed the initial state right after. - addRelation(sameAsBilling, (value) => value, - (same) => shipping.setValidationEnabled(!same)); - shipping.setValidationEnabled(!sameAsBilling.fieldValue); + addSubform(shipping, enabled: () => !sameAsBilling.fieldValue); } final email = AdvancedTextFieldController(validator: filled(MyError.required)); @@ -760,13 +755,40 @@ class CheckoutFormController extends AdvancedFormController { } ``` +The closure runs at `addSubform` and again after every value change anywhere in this form's +tree. True → the section is attached; false → detached, exactly like `removeSubform`: out of +`validate`, `canSubmit`, `getFieldValues`, `allFields` and every broadcast. **The form owns +the section either way**, so its values stay in its fields and are back when the switch flips, +and it is disposed with the form. The closure can be any boolean expression over the fields' +values. `resetAll()` re-triggers it, so a reset switch takes the section with it. Do **not** write `addRelation` + `addSubform`/`removeSubform` +for this. A condition on something *outside* the form (a service, a route argument) is not +re-evaluated for that — attach and detach by hand then. + +**Which to use:** `enabled:` when the decision is a function of the form's own values; the two +calls by hand when it comes from outside the form. They do not fight — **the last call by hand +wins**: a manual `removeSubform` detaches the section *and drops the condition* (the section +stays out until the next `addSubform`), a plain `addSubform` attaches it for good, `addSubform` +with a new `enabled:` replaces the condition. + +### Or keep it attached and switch validation off + +Detached, a section is out of reach of `resetAll`, `markReadOnly`, `clearErrors` and +`setValidationMode`, and its values are not in `getFieldValues()`. When those must keep +reaching a section that merely does not apply right now, keep it attached and use +`setValidationEnabled(false)`: + +```dart +addSubform(invoice); +// addRelation fires on change only, so seed the initial state right after. +addRelation(needsInvoice, (on) => on, invoice.setValidationEnabled); +invoice.setValidationEnabled(needsInvoice.fieldValue); +``` + A switched-off subtree **stops counting entirely**: it validates nothing, its `validate()` returns `true` unrun, and its fields leave `canSubmit`, `validating`, `hasFailedValidation` and -`validationErrors`. That one flag is the whole condition — no mode to reset, no error to clear -by hand. Switching off clears the subtree's **errors** and leaves every **value** untouched, -which is exactly why this beats detaching for a section the user may toggle back on. Switching -back on re-runs the sync validators, still subject to the three rules — so a field the user -never edited stays quiet and a re-appearing section does not paint itself red. +`validationErrors`. Switching off clears the subtree's **errors** and leaves every **value** +untouched. Switching back on re-runs the sync validators, still subject to the three rules — so +a field the user never edited stays quiet and a re-appearing section does not paint itself red. `validationEnabled` starts `true` and only `setValidationEnabled` writes it — `resetAll()` leaves it alone, so seed it at construction and again in whatever method calls `resetAll()`. @@ -792,7 +814,8 @@ void disableGift() => removeSubform(gift); // detaches only — `gift` is NOT di time — the mode is broadcast on attach and `validate()` changes nothing. No fix-up needed. - A subform with its own `validationMode:` keeps it and stops following the parent's. - `addSubform` is a no-op when already attached, `removeSubform` when not — a toggle needs no - bookkeeping flag. + bookkeeping flag. `addSubform` with `enabled:` is the exception: given again, it replaces + the condition. `removeSubform` drops the condition — the last call by hand wins. ### A wizard, validated step by step diff --git a/test/src/form/form_controller_test.dart b/test/src/form/form_controller_test.dart index 1614aae..8356c42 100644 --- a/test/src/form/form_controller_test.dart +++ b/test/src/form/form_controller_test.dart @@ -760,6 +760,207 @@ void main() { }); }); + group('addSubform with enabled', () { + late AdvancedBooleanFieldController<_Error1> needsInvoice; + late AdvancedFieldController customerType; + + setUp(() { + needsInvoice = AdvancedBooleanFieldController<_Error1>(); + customerType = AdvancedFieldController(initialValue: 'person'); + subform.registerFields([subformField]); + form.registerFields([field1, field2, needsInvoice, customerType]); + }); + + tearDown(() => form.dispose()); + + test('a false condition leaves the section detached, but owned', () { + final section = AdvancedFormController(); + final parent = AdvancedFormController() + ..addSubform(section, enabled: () => false); + + expect(parent.value.subforms, isEmpty); + + parent.dispose(); + + expect(section.isDisposed, true); + }); + + test('a true condition attaches at once', () { + needsInvoice.setValue(true); + + form.addSubform(subform, enabled: () => needsInvoice.fieldValue); + + expect(form.value.subforms, {subform}); + }); + + test('attaches and detaches as the fields it reads change', () { + form.addSubform( + subform, + enabled: () => + needsInvoice.fieldValue || customerType.fieldValue == 'company', + ); + + needsInvoice.setValue(true); + expect(form.value.subforms, {subform}); + + needsInvoice.setValue(false); + expect(form.value.subforms, isEmpty); + + customerType.setValue('company'); + expect(form.value.subforms, {subform}); + }); + + test('the values of a detached section survive and come back', () { + needsInvoice.setValue(true); + form.addSubform(subform, enabled: () => needsInvoice.fieldValue); + subformField.setValue(42); + + needsInvoice.setValue(false); + expect(form.getFieldValues(), isNot(contains(42))); + expect(subformField.fieldValue, 42); + + needsInvoice.setValue(true); + expect(form.value.allFields, contains(subformField)); + expect(subformField.fieldValue, 42); + }); + + test('a change that leaves the result the same does nothing', () { + form.addSubform(subform, enabled: () => needsInvoice.fieldValue); + final emissions = _record(form); + + field1.setValue('other'); + needsInvoice.setError(_Error1.valueRequired); + + expect(emissions.map((e) => e.subforms), everyElement(isEmpty)); + }); + + test('a value change inside another subform re-evaluates too', () { + final inner = AdvancedBooleanFieldController<_Error1>(); + final innerForm = AdvancedFormController()..registerFields([inner]); + form + ..addSubform(innerForm) + ..addSubform(subform, enabled: () => inner.fieldValue); + + inner.setValue(true); + + expect(form.value.subforms, {innerForm, subform}); + }); + + test('a detached section is out of validate() and canSubmit', () async { + final sectionField = AdvancedFieldController( + initialValue: 0, + validator: (_) => _Error2.malformed, + ); + final section = AdvancedFormController() + ..registerFields([sectionField]); + form.addSubform(section, enabled: () => needsInvoice.fieldValue); + + expect(await form.validate(), true); + expect(sectionField.value.error, null); + + needsInvoice.setValue(true); + + expect(await form.validate(), false); + expect(sectionField.value.error, _Error2.malformed); + }); + + test('the parent mode reaches a section that is attached later', () { + form + ..setValidationMode(ValidationMode.onUserInteraction) + ..addSubform(subform, enabled: () => needsInvoice.fieldValue); + + needsInvoice.setValue(true); + + expect( + subform.value.validationMode, + ValidationMode.onUserInteraction, + ); + }); + + test('resetAll brings the section back in step with the field', () { + form.addSubform(subform, enabled: () => needsInvoice.fieldValue); + needsInvoice.setValue(true); + expect(form.value.subforms, {subform}); + + form.resetAll(); + + expect(form.value.subforms, isEmpty); + }); + + test('removeSubform drops the condition', () { + needsInvoice.setValue(true); + form + ..addSubform(subform, enabled: () => needsInvoice.fieldValue) + ..removeSubform(subform); + + needsInvoice + ..setValue(false) + ..setValue(true); + + expect(form.value.subforms, isEmpty); + }); + + test('removeSubform on a section the condition detached drops it too', + () { + form + ..addSubform(subform, enabled: () => needsInvoice.fieldValue) + ..removeSubform(subform); + + needsInvoice.setValue(true); + + expect(form.value.subforms, isEmpty); + }); + + test('re-attaching without a condition attaches for good', () { + form + ..addSubform(subform, enabled: () => needsInvoice.fieldValue) + ..removeSubform(subform) + ..addSubform(subform); + + needsInvoice + ..setValue(true) + ..setValue(false); + + expect(form.value.subforms, {subform}); + }); + + test('a condition given again replaces the previous one', () { + form + ..addSubform(subform, enabled: () => needsInvoice.fieldValue) + ..addSubform(subform, enabled: () => !needsInvoice.fieldValue); + + expect(form.value.subforms, {subform}); + + needsInvoice.setValue(true); + + expect(form.value.subforms, isEmpty); + }); + + test('a plain addSubform of an attached section stays a noop', () { + needsInvoice.setValue(true); + form.addSubform(subform, enabled: () => needsInvoice.fieldValue); + final emissions = _record(form); + + form.addSubform(subform); + + expect(emissions, isEmpty); + }); + + test( + 'a condition can read an unregistered field, but does not follow ' + 'it', () { + final outside = AdvancedBooleanFieldController<_Error1>(); + addTearDown(outside.dispose); + form.addSubform(subform, enabled: () => outside.fieldValue); + + outside.setValue(true); + expect(form.value.subforms, isEmpty); + + field1.setValue('any change in the form re-evaluates'); + expect(form.value.subforms, {subform}); + }); + }); + group('validateAll', () { late AdvancedFormController validateAllForm;