From 162e71bd7566d4c898eab14e90c852b54e9a6d5d Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sun, 20 Sep 2026 22:48:22 +0200 Subject: [PATCH 1/4] =?UTF-8?q?A=20conditional=20section=20in=20one=20line?= =?UTF-8?q?:=20addSubform(form,=20enabled:=20()=20=3D>=20=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The closure is evaluated on attach and after every value change in the form; its result switches the section's validation on and off. Replaces the addRelation + setValidationEnabled + manual seed pattern, and a resetAll() brings the section back in step by itself. Piotr Denert's idea. Example wizard, docs and skill updated. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 1 + docs/subforms/index.mdx | 3 + docs/subforms/patterns.mdx | 27 ++-- docs/subforms/wizard.mdx | 8 +- docs/validation/relations.mdx | 6 +- example/lib/screens/step_form.dart | 51 +++++--- lib/src/form/advanced_form_controller.dart | 49 +++++++- lib/src/form/child_wiring.dart | 4 + skills/advanced_forms-build-forms/SKILL.md | 29 +++-- test/src/form/form_controller_test.dart | 140 +++++++++++++++++++++ 10 files changed, 270 insertions(+), 48 deletions(-) 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..baca77e 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 validates and counts. See + [Patterns](./patterns.mdx#detach-or-switch-validation-off). - **`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..4562d2c 100644 --- a/docs/subforms/patterns.mdx +++ b/docs/subforms/patterns.mdx @@ -12,24 +12,31 @@ AI-Provenance: ## Detach, or switch validation off? 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)`: +better kept attached and switched off. `addSubform` takes the condition: + +```dart +CheckoutFormController() { + registerFields([email, needsInvoice]); + addSubform(invoice, enabled: () => needsInvoice.fieldValue); +} +``` + +The closure is evaluated when the section is attached and again whenever a value anywhere in the form changes, and +its result drives the section's `validationEnabled`. Switched off: - 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. +Any expression works: `() => type.fieldValue == CustomerType.company`, `() => !sameAsBilling.fieldValue`, a condition +over two fields. Because the form re-evaluates it after `resetAll()` too, a reset checkbox brings the section back in +step by itself. A condition that depends on something *outside* the form — a service, a route argument — is not +re-evaluated for that; call `setValidationEnabled` on the section yourself when it changes. + 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. - -```dart -CheckoutFormController() { - registerFields([email, needsInvoice]); - addSubform(invoice); - addRelation(needsInvoice, (on) => on, invoice.setValidationEnabled); - invoice.setValidationEnabled(needsInvoice.fieldValue); // relations fire on change only -} -``` +`removeSubform` drops the condition; re-attach with a new one if it still applies. ## Swapping one section for another diff --git a/docs/subforms/wizard.mdx b/docs/subforms/wizard.mdx index ddd8e52..a98b341 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 stays attached, switched off.** The example app's *Step Form* screen attaches its invoice step + with `addSubform(invoice, enabled: () => address.needsInvoice.fieldValue)`. 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. diff --git a/docs/validation/relations.mdx b/docs/validation/relations.mdx index cd86e68..50e7d2c 100644 --- a/docs/validation/relations.mdx +++ b/docs/validation/relations.mdx @@ -111,8 +111,9 @@ cannot loop. Three details matter in practice: - **Destructive relations are safe.** `addRelation(country, (c) => c, (_) => city.reset())` clears the city when the 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. +Attaching and detaching subforms from `onChange` is supported too — that is how a type selector swaps one section for +another. A section that only needs switching on and off 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())` | +| switch a section on and off 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..ad352bd 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** stays attached, switched off through `addSubform`'s +/// `enabled:`: the navigation walks past it and its fields stop counting +/// towards the parent. 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: it stays attached, so its values, its + // fields and its disposal stay with the wizard, and it validates and counts + // only while the switch on the Address step is on. The form re-evaluates + // the condition itself whenever a value in it changes. + addSubform(invoice, enabled: () => address.needsInvoice.fieldValue); + addSubform(confirm); + + // Navigation follows the flag: the flow is the steps that are switched on. + invoice.addListener(_syncActiveSteps); + _syncActiveSteps(); } final account = AccountStepController(); @@ -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 (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..a78df30 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; dropped on detach. + final Map _subformConditions = {}; final _validateCall = SharedCall(); // null: follow the parent form's mode. Non-null: this form manages its own. @@ -201,9 +204,26 @@ class AdvancedFormController /// Ownership outlives [removeSubform]: a detached subform is still disposed /// with this form, the same way a deregistered field is. /// + /// [enabled] makes the section conditional: + /// + /// ```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, and its result drives the section's + /// [setValidationEnabled]: switched off, the section validates nothing and + /// leaves `canSubmit`, its errors are cleared and its values kept. The + /// section stays attached, so `resetAll`, `markReadOnly` and the rest still + /// reach it, and a reset of the deciding field brings it back in step by + /// itself. A condition that depends on something outside the form is not + /// re-evaluated for it; call [setValidationEnabled] on the section yourself + /// in that case. [removeSubform] drops 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.', @@ -225,6 +245,24 @@ class AdvancedFormController _wireChildren(); _publishValidationMode(value.validationMode); _recomputeWasModified(); + + if (enabled != null) { + final result = enabled(); + _subformConditions[form] = _SubformCondition(enabled, result); + form.setValidationEnabled(result); + } + } + + @override + void _applySubformConditions() { + for (final entry in _subformConditions.entries) { + final condition = entry.value; + final next = condition.enabled(); + if (next != condition.last) { + condition.last = next; + entry.key.setValidationEnabled(next); + } + } } /// Detaches an owned subform: it stops validating, notifying and counting @@ -245,6 +283,7 @@ class AdvancedFormController return; } + _subformConditions.remove(form); _runChildCleanups(); _setState(value.copyWith(subforms: {...value.subforms}..remove(form))); _wireChildren(); @@ -274,6 +313,7 @@ class AdvancedFormController _isDisposed = true; _runChildCleanups(); _runRelationCleanups(); + _subformConditions.clear(); for (final field in _ownedFields) { field.dispose(); } @@ -358,3 +398,10 @@ class AdvancedFormController notifyListeners(); } } + +class _SubformCondition { + _SubformCondition(this.enabled, this.last); + + final 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..c7b1206 100644 --- a/skills/advanced_forms-build-forms/SKILL.md +++ b/skills/advanced_forms-build-forms/SKILL.md @@ -550,8 +550,8 @@ MyForm() { - The form removes the listener in its own `dispose()`. Throws a `StateError` if the form or `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. + changes no values, so it cannot wipe the user's choice. Attaching or detaching subforms from + `onChange` is supported; switching a section on and off 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 +672,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); // …kept in step with a field ``` Push server errors **after** the `await`, never before: `validate()` and anything else that @@ -742,16 +743,13 @@ Split big forms, or attach sections that appear dynamically. Subform fields join Prefer this. The subform stays in the tree, so `resetAll`, `markReadOnly`, `clearErrors`, `setValidationMode` and `dispose()` all still reach it, and no ownership changes hands. +`addSubform` takes the condition as a closure: ```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,6 +758,14 @@ class CheckoutFormController extends AdvancedFormController { } ``` +The closure runs at `addSubform` and again after every value change anywhere in this form's +tree; when its result flips, the form calls `setValidationEnabled` on the section. Any +expression goes — `() => type.fieldValue == CustomerType.company`, a condition over two +fields. Do **not** write the old `addRelation` + `setValidationEnabled` + manual seed for +this; `enabled:` is that pattern with the seeding and the `resetAll()` re-sync built in. A +condition on something *outside* the form (a service, a route argument) is not re-evaluated +for that — call `setValidationEnabled` on the section yourself then. + 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 @@ -769,8 +775,9 @@ back on re-runs the sync validators, still subject to the three rules — so a f 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()`. -It composes with a parent's switch by AND, so a section that opted out stays out. +leaves it alone. With `enabled:` that is no concern: the reset values re-trigger the closure. +With a hand-written `setValidationEnabled`, seed it at construction and again after +`resetAll()`. It composes with a parent's switch by AND, so a section that opted out stays out. (`wasModified` is the exception: a switched-off subform still reports its modifications.) ### A section that genuinely appears and disappears — attach and detach @@ -791,8 +798,8 @@ void disableGift() => removeSubform(gift); // detaches only — `gift` is NOT di - A subform attached *after* the first `validate()` behaves exactly like one attached at build 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. +- `addSubform` is a no-op when already attached (also with a new `enabled:`), `removeSubform` + when not — a toggle needs no bookkeeping flag. `removeSubform` drops the condition. ### 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..f49d0a2 100644 --- a/test/src/form/form_controller_test.dart +++ b/test/src/form/form_controller_test.dart @@ -760,6 +760,146 @@ 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('is applied at once', () { + form.addSubform(subform, enabled: () => needsInvoice.fieldValue); + + expect(form.value.subforms, {subform}); + expect(subform.value.validationEnabled, false); + }); + + test('follows the fields it reads from then on', () { + form.addSubform( + subform, + enabled: () => + needsInvoice.fieldValue || customerType.fieldValue == 'company', + ); + + needsInvoice.setValue(true); + expect(subform.value.validationEnabled, true); + + needsInvoice.setValue(false); + expect(subform.value.validationEnabled, false); + + customerType.setValue('company'); + expect(subform.value.validationEnabled, true); + }); + + test( + 'a change that leaves the result the same does not touch the ' + 'section', () { + form.addSubform(subform, enabled: () => needsInvoice.fieldValue); + final emissions = _record(subform); + + field1.setValue('other'); + needsInvoice.setError(_Error1.valueRequired); + + expect(emissions, isEmpty); + }); + + test('a value change inside a 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(subform.value.validationEnabled, true); + }); + + test('switched off, the section validates nothing and leaves 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('resetAll brings the section back in step with the field', () { + form.addSubform(subform, enabled: () => needsInvoice.fieldValue); + needsInvoice.setValue(true); + expect(subform.value.validationEnabled, true); + + form.resetAll(); + + expect(subform.value.validationEnabled, false); + }); + + test('removeSubform drops the condition', () { + form + ..addSubform(subform, enabled: () => needsInvoice.fieldValue) + ..removeSubform(subform); + subform.setValidationEnabled(true); + + needsInvoice + ..setValue(true) + ..setValue(false); + + expect(subform.value.validationEnabled, true); + }); + + test('re-attaching without a condition leaves the section as it is', () { + form + ..addSubform(subform, enabled: () => needsInvoice.fieldValue) + ..removeSubform(subform) + ..addSubform(subform); + + expect(subform.value.validationEnabled, false); + + needsInvoice.setValue(true); + + expect(subform.value.validationEnabled, false); + }); + + test('is a noop on an already attached subform', () { + form + ..addSubform(subform) + ..addSubform(subform, enabled: () => needsInvoice.fieldValue); + + expect(subform.value.validationEnabled, true); + }); + + 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); + expect(subform.value.validationEnabled, false); + + outside.setValue(true); + expect(subform.value.validationEnabled, false); + + field1.setValue('any change in the form re-evaluates'); + expect(subform.value.validationEnabled, true); + }); + }); + group('validateAll', () { late AdvancedFormController validateAllForm; From cf91d25f10fa082d4d082446bc1d320108190f23 Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Sun, 20 Sep 2026 22:57:44 +0200 Subject: [PATCH 2/4] Conditional sections attach and detach instead of switching validation Kamil's call: a section whose condition is false leaves the form, so the payload built from the form has none of its fields. The form still owns it, so its values survive and come back when the condition flips. Example wizard, docs and skill follow. Co-Authored-By: Claude Fable 5.1 --- docs/subforms/index.mdx | 4 +- docs/subforms/patterns.mdx | 42 ++++--- docs/subforms/wizard.mdx | 8 +- docs/validation/relations.mdx | 8 +- example/lib/screens/step_form.dart | 26 ++--- lib/src/form/advanced_form_controller.dart | 72 +++++++----- skills/advanced_forms-build-forms/SKILL.md | 59 ++++++---- test/src/form/form_controller_test.dart | 125 +++++++++++++++------ 8 files changed, 226 insertions(+), 118 deletions(-) diff --git a/docs/subforms/index.mdx b/docs/subforms/index.mdx index baca77e..c20c411 100644 --- a/docs/subforms/index.mdx +++ b/docs/subforms/index.mdx @@ -186,8 +186,8 @@ 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 validates and counts. See - [Patterns](./patterns.mdx#detach-or-switch-validation-off). + 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 4562d2c..fe95ad0 100644 --- a/docs/subforms/patterns.mdx +++ b/docs/subforms/patterns.mdx @@ -9,10 +9,10 @@ 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 and switched off. `addSubform` takes the condition: +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() { @@ -21,22 +21,36 @@ CheckoutFormController() { } ``` -The closure is evaluated when the section is attached and again whenever a value anywhere in the form changes, and -its result drives the section's `validationEnabled`. Switched off: +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. + +Any expression works: `() => type.fieldValue == CustomerType.company`, `() => !sameAsBilling.fieldValue`, a condition +over two fields. 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. `removeSubform` drops 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; -- switching back on re-runs the sync validators at once, and the values are still there. +- 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. -Any expression works: `() => type.fieldValue == CustomerType.company`, `() => !sameAsBilling.fieldValue`, a condition -over two fields. Because the form re-evaluates it after `resetAll()` too, a reset checkbox brings the section back in -step by itself. A condition that depends on something *outside* the form — a service, a route argument — is not -re-evaluated for that; call `setValidationEnabled` on the section yourself when it changes. +```dart +addSubform(invoice); +addRelation(needsInvoice, (on) => on, invoice.setValidationEnabled); +invoice.setValidationEnabled(needsInvoice.fieldValue); // relations fire on change only +``` -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. -`removeSubform` drops the condition; re-attach with a new one if it still applies. +`resetAll()` does not touch `validationEnabled`, so seed it again wherever you reset. ## Swapping one section for another diff --git a/docs/subforms/wizard.mdx b/docs/subforms/wizard.mdx index a98b341..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, switched off.** The example app's *Step Form* screen attaches its invoice step - with `addSubform(invoice, enabled: () => address.needsInvoice.fieldValue)`. 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 50e7d2c..99c19ed 100644 --- a/docs/validation/relations.mdx +++ b/docs/validation/relations.mdx @@ -111,9 +111,9 @@ cannot loop. Three details matter in practice: - **Destructive relations are safe.** `addRelation(country, (c) => c, (_) => city.reset())` clears the city when the country changes and nothing else can wipe the user's choice; the [wizard](../subforms/wizard.mdx) does exactly that. -Attaching and detaching subforms from `onChange` is supported too — that is how a type selector swaps one section for -another. A section that only needs switching on and off does not need a relation at all: -`addSubform(section, enabled: () => …)` keeps the condition itself, see [Patterns](../subforms/patterns.mdx). +Attaching and detaching subforms, or calling `setValidationEnabled`, from `onChange` is supported too — that is how a +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? @@ -124,5 +124,5 @@ another. A section that only needs switching on and off does not need a relation | 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())` | -| switch a section on and off with A | `addSubform(section, enabled: () => A.fieldValue …)` | +| 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 ad352bd..2260f41 100644 --- a/example/lib/screens/step_form.dart +++ b/example/lib/screens/step_form.dart @@ -18,9 +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, switched off through `addSubform`'s -/// `enabled:`: 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}); @@ -369,15 +369,15 @@ class StepFormController extends AdvancedFormController { StepFormController() { addSubform(account); addSubform(address); - // The Invoice step is conditional: it stays attached, so its values, its - // fields and its disposal stay with the wizard, and it validates and counts - // only while the switch on the Address step is on. The form re-evaluates - // the condition itself whenever a value in it changes. + // 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 flag: the flow is the steps that are switched on. - invoice.addListener(_syncActiveSteps); + // Navigation follows: the flow is the steps that are attached right now. + addListener(_syncActiveSteps); _syncActiveSteps(); } @@ -388,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 = []; @@ -465,7 +465,7 @@ class StepFormController extends AdvancedFormController { 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; diff --git a/lib/src/form/advanced_form_controller.dart b/lib/src/form/advanced_form_controller.dart index a78df30..b617749 100644 --- a/lib/src/form/advanced_form_controller.dart +++ b/lib/src/form/advanced_form_controller.dart @@ -61,7 +61,7 @@ class AdvancedFormController final Set> _ownedFields = {}; final Set _ownedSubforms = {}; // Conditional sections: the `enabled` closure and its last result. - // Re-evaluated whenever a value in this tree changes; dropped on detach. + // Re-evaluated whenever a value in this tree changes; attaches and detaches. final Map _subformConditions = {}; final _validateCall = SharedCall(); @@ -212,14 +212,14 @@ class AdvancedFormController /// ``` /// /// It is evaluated now and again whenever a value anywhere in this form's - /// tree changes, and its result drives the section's - /// [setValidationEnabled]: switched off, the section validates nothing and - /// leaves `canSubmit`, its errors are cleared and its values kept. The - /// section stays attached, so `resetAll`, `markReadOnly` and the rest still - /// reach it, and a reset of the deciding field brings it back in step by - /// itself. A condition that depends on something outside the form is not - /// re-evaluated for it; call [setValidationEnabled] on the section yourself - /// in that case. [removeSubform] drops the condition. + /// 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. + /// A condition that depends on something outside the form is not + /// re-evaluated for it; attach and detach by hand in that case. + /// [removeSubform] drops the condition. /// /// Throws a [StateError] if either controller has already been disposed — /// disposed controllers cannot be reused. @@ -234,10 +234,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] = _SubformCondition(enabled, 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})); @@ -245,12 +264,13 @@ class AdvancedFormController _wireChildren(); _publishValidationMode(value.validationMode); _recomputeWasModified(); + } - if (enabled != null) { - final result = enabled(); - _subformConditions[form] = _SubformCondition(enabled, result); - form.setValidationEnabled(result); - } + void _detach(AdvancedFormController form) { + _runChildCleanups(); + _setState(value.copyWith(subforms: {...value.subforms}..remove(form))); + _wireChildren(); + _recomputeWasModified(); } @override @@ -258,15 +278,21 @@ class AdvancedFormController for (final entry in _subformConditions.entries) { final condition = entry.value; final next = condition.enabled(); - if (next != condition.last) { - condition.last = next; - entry.key.setValidationEnabled(next); + if (next == condition.last) { + continue; + } + condition.last = next; + if (next) { + _attach(entry.key); + } else { + _detach(entry.key); } } } /// 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]. @@ -279,15 +305,11 @@ class AdvancedFormController 'Cannot remove a subform from a disposed AdvancedFormController.', ); } + _subformConditions.remove(form); if (!value.subforms.contains(form)) { return; } - - _subformConditions.remove(form); - _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. diff --git a/skills/advanced_forms-build-forms/SKILL.md b/skills/advanced_forms-build-forms/SKILL.md index c7b1206..7de8dc8 100644 --- a/skills/advanced_forms-build-forms/SKILL.md +++ b/skills/advanced_forms-build-forms/SKILL.md @@ -550,8 +550,9 @@ MyForm() { - The form removes the listener in its own `dispose()`. Throws a `StateError` if the form or `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 from - `onChange` is supported; switching a section on and off is `addSubform(…, enabled:)` instead. + changes no values, so it cannot wipe the user's choice. Attaching or detaching subforms and + 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,7 +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); // …kept in step with a field +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 @@ -739,11 +740,7 @@ 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. -`addSubform` takes the condition as a closure: +### A section behind a switch — `addSubform(…, enabled:)` ```dart class CheckoutFormController extends AdvancedFormController { @@ -759,25 +756,38 @@ class CheckoutFormController extends AdvancedFormController { ``` The closure runs at `addSubform` and again after every value change anywhere in this form's -tree; when its result flips, the form calls `setValidationEnabled` on the section. Any -expression goes — `() => type.fieldValue == CustomerType.company`, a condition over two -fields. Do **not** write the old `addRelation` + `setValidationEnabled` + manual seed for -this; `enabled:` is that pattern with the seeding and the `resetAll()` re-sync built in. A -condition on something *outside* the form (a service, a route argument) is not re-evaluated -for that — call `setValidationEnabled` on the section yourself then. +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. Any expression goes — `() => type.fieldValue == +CustomerType.company`, a condition over two fields. `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. `removeSubform` drops 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. With `enabled:` that is no concern: the reset values re-trigger the closure. -With a hand-written `setValidationEnabled`, seed it at construction and again after -`resetAll()`. It composes with a parent's switch by AND, so a section that opted out stays out. +leaves it alone, so seed it at construction and again in whatever method calls `resetAll()`. +It composes with a parent's switch by AND, so a section that opted out stays out. (`wasModified` is the exception: a switched-off subform still reports its modifications.) ### A section that genuinely appears and disappears — attach and detach @@ -798,8 +808,9 @@ void disableGift() => removeSubform(gift); // detaches only — `gift` is NOT di - A subform attached *after* the first `validate()` behaves exactly like one attached at build 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 (also with a new `enabled:`), `removeSubform` - when not — a toggle needs no bookkeeping flag. `removeSubform` drops the condition. +- `addSubform` is a no-op when already attached, `removeSubform` when not — a toggle needs no + bookkeeping flag. `addSubform` with `enabled:` is the exception: given again, it replaces + the condition. `removeSubform` drops the condition. ### 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 f49d0a2..8356c42 100644 --- a/test/src/form/form_controller_test.dart +++ b/test/src/form/form_controller_test.dart @@ -773,14 +773,27 @@ void main() { tearDown(() => form.dispose()); - test('is applied at once', () { + 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}); - expect(subform.value.validationEnabled, false); }); - test('follows the fields it reads from then on', () { + test('attaches and detaches as the fields it reads change', () { form.addSubform( subform, enabled: () => @@ -788,28 +801,40 @@ void main() { ); needsInvoice.setValue(true); - expect(subform.value.validationEnabled, true); + expect(form.value.subforms, {subform}); needsInvoice.setValue(false); - expect(subform.value.validationEnabled, false); + expect(form.value.subforms, isEmpty); customerType.setValue('company'); - expect(subform.value.validationEnabled, true); + expect(form.value.subforms, {subform}); }); - test( - 'a change that leaves the result the same does not touch the ' - 'section', () { + 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(subform); + final emissions = _record(form); field1.setValue('other'); needsInvoice.setError(_Error1.valueRequired); - expect(emissions, isEmpty); + expect(emissions.map((e) => e.subforms), everyElement(isEmpty)); }); - test('a value change inside a subform re-evaluates too', () { + test('a value change inside another subform re-evaluates too', () { final inner = AdvancedBooleanFieldController<_Error1>(); final innerForm = AdvancedFormController()..registerFields([inner]); form @@ -818,11 +843,10 @@ void main() { inner.setValue(true); - expect(subform.value.validationEnabled, true); + expect(form.value.subforms, {innerForm, subform}); }); - test('switched off, the section validates nothing and leaves canSubmit', - () async { + test('a detached section is out of validate() and canSubmit', () async { final sectionField = AdvancedFieldController( initialValue: 0, validator: (_) => _Error2.malformed, @@ -840,48 +864,86 @@ void main() { 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(subform.value.validationEnabled, true); + expect(form.value.subforms, {subform}); form.resetAll(); - expect(subform.value.validationEnabled, false); + expect(form.value.subforms, isEmpty); }); test('removeSubform drops the condition', () { + needsInvoice.setValue(true); form ..addSubform(subform, enabled: () => needsInvoice.fieldValue) ..removeSubform(subform); - subform.setValidationEnabled(true); needsInvoice - ..setValue(true) - ..setValue(false); + ..setValue(false) + ..setValue(true); - expect(subform.value.validationEnabled, true); + expect(form.value.subforms, isEmpty); }); - test('re-attaching without a condition leaves the section as it is', () { + 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); - expect(subform.value.validationEnabled, false); + 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(subform.value.validationEnabled, false); + expect(form.value.subforms, isEmpty); }); - test('is a noop on an already attached subform', () { - form - ..addSubform(subform) - ..addSubform(subform, enabled: () => needsInvoice.fieldValue); + test('a plain addSubform of an attached section stays a noop', () { + needsInvoice.setValue(true); + form.addSubform(subform, enabled: () => needsInvoice.fieldValue); + final emissions = _record(form); - expect(subform.value.validationEnabled, true); + form.addSubform(subform); + + expect(emissions, isEmpty); }); test( @@ -890,13 +952,12 @@ void main() { final outside = AdvancedBooleanFieldController<_Error1>(); addTearDown(outside.dispose); form.addSubform(subform, enabled: () => outside.fieldValue); - expect(subform.value.validationEnabled, false); outside.setValue(true); - expect(subform.value.validationEnabled, false); + expect(form.value.subforms, isEmpty); field1.setValue('any change in the form re-evaluates'); - expect(subform.value.validationEnabled, true); + expect(form.value.subforms, {subform}); }); }); From 7cea2718e708015cb50b0056711db37dae574653 Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Tue, 22 Sep 2026 16:42:04 +0200 Subject: [PATCH 3/4] Say when to use enabled: and what a manual removeSubform does to it The last call by hand wins: removeSubform detaches and drops the condition, a plain addSubform attaches for good. In the Dart docs, the patterns page and the skill. Co-Authored-By: Claude Fable 5.1 --- docs/subforms/patterns.mdx | 7 ++++++- lib/src/form/advanced_form_controller.dart | 15 +++++++++++---- skills/advanced_forms-build-forms/SKILL.md | 10 ++++++++-- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/docs/subforms/patterns.mdx b/docs/subforms/patterns.mdx index fe95ad0..6ee8e86 100644 --- a/docs/subforms/patterns.mdx +++ b/docs/subforms/patterns.mdx @@ -30,7 +30,12 @@ checkbox goes back on — and the section is disposed with the form. Any expression works: `() => type.fieldValue == CustomerType.company`, `() => !sameAsBilling.fieldValue`, a condition over two fields. 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. `removeSubform` drops the condition. +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 diff --git a/lib/src/form/advanced_form_controller.dart b/lib/src/form/advanced_form_controller.dart index b617749..e46a2c7 100644 --- a/lib/src/form/advanced_form_controller.dart +++ b/lib/src/form/advanced_form_controller.dart @@ -204,7 +204,8 @@ class AdvancedFormController /// Ownership outlives [removeSubform]: a detached subform is still disposed /// with this form, the same way a deregistered field is. /// - /// [enabled] makes the section conditional: + /// [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); @@ -217,9 +218,15 @@ class AdvancedFormController /// `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. - /// A condition that depends on something outside the form is not - /// re-evaluated for it; attach and detach by hand in that case. - /// [removeSubform] drops the condition. + /// + /// 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. diff --git a/skills/advanced_forms-build-forms/SKILL.md b/skills/advanced_forms-build-forms/SKILL.md index 7de8dc8..9a56de9 100644 --- a/skills/advanced_forms-build-forms/SKILL.md +++ b/skills/advanced_forms-build-forms/SKILL.md @@ -763,7 +763,13 @@ and it is disposed with the form. Any expression goes — `() => type.fieldValue CustomerType.company`, a condition over two fields. `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. `removeSubform` drops the condition. +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 @@ -810,7 +816,7 @@ void disableGift() => removeSubform(gift); // detaches only — `gift` is NOT di - 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. `addSubform` with `enabled:` is the exception: given again, it replaces - the condition. `removeSubform` drops the condition. + the condition. `removeSubform` drops the condition — the last call by hand wins. ### A wizard, validated step by step From 1cf2ddf431e2275baa3c523ae7820bc9b4cf2a5e Mon Sep 17 00:00:00 2001 From: Kamil Sztandur Date: Wed, 23 Sep 2026 14:11:13 +0200 Subject: [PATCH 4/4] Review: a record for the condition, a destructured loop, plainer docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Piotruś's four comments on #91: the condition is a typedef of a record and the loop destructures the entry; the docs and the skill say the closure is any boolean expression over the fields' values instead of listing examples. Co-Authored-By: Claude Fable 5.1 --- docs/subforms/patterns.mdx | 4 ++-- lib/src/form/advanced_form_controller.dart | 25 ++++++++++------------ skills/advanced_forms-build-forms/SKILL.md | 5 ++--- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/docs/subforms/patterns.mdx b/docs/subforms/patterns.mdx index 6ee8e86..41ab710 100644 --- a/docs/subforms/patterns.mdx +++ b/docs/subforms/patterns.mdx @@ -27,8 +27,8 @@ section is attached; when it turns false the section is detached, exactly as `re 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. -Any expression works: `() => type.fieldValue == CustomerType.company`, `() => !sameAsBilling.fieldValue`, a condition -over two fields. Because the form re-evaluates it after `resetAll()` too, a reset checkbox takes the section with it. +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. diff --git a/lib/src/form/advanced_form_controller.dart b/lib/src/form/advanced_form_controller.dart index e46a2c7..883b247 100644 --- a/lib/src/form/advanced_form_controller.dart +++ b/lib/src/form/advanced_form_controller.dart @@ -253,7 +253,7 @@ class AdvancedFormController _subformConditions.remove(form); _ownedSubforms.add(form); final result = enabled(); - _subformConditions[form] = _SubformCondition(enabled, result); + _subformConditions[form] = (enabled: enabled, last: result); if (result) { if (!value.subforms.contains(form)) { _attach(form); @@ -282,17 +282,18 @@ class AdvancedFormController @override void _applySubformConditions() { - for (final entry in _subformConditions.entries) { - final condition = entry.value; - final next = condition.enabled(); - if (next == condition.last) { + // 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; } - condition.last = next; + _subformConditions[form] = (enabled: enabled, last: next); if (next) { - _attach(entry.key); + _attach(form); } else { - _detach(entry.key); + _detach(form); } } } @@ -428,9 +429,5 @@ class AdvancedFormController } } -class _SubformCondition { - _SubformCondition(this.enabled, this.last); - - final bool Function() enabled; - bool last; -} +/// The `enabled` closure of a conditional subform and its last result. +typedef _SubformCondition = ({bool Function() enabled, bool last}); diff --git a/skills/advanced_forms-build-forms/SKILL.md b/skills/advanced_forms-build-forms/SKILL.md index 9a56de9..03da8bf 100644 --- a/skills/advanced_forms-build-forms/SKILL.md +++ b/skills/advanced_forms-build-forms/SKILL.md @@ -759,9 +759,8 @@ The closure runs at `addSubform` and again after every value change anywhere in 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. Any expression goes — `() => type.fieldValue == -CustomerType.company`, a condition over two fields. `resetAll()` re-triggers it, so a reset -switch takes the section with it. Do **not** write `addRelation` + `addSubform`/`removeSubform` +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.