joanpablo / reactive_forms

This is a model-driven approach to handling form inputs and validations, heavily inspired in Angular's Reactive Forms
MIT License
469 stars 86 forks source link

[Question] How to reactively display `FormArray` errors #411

Open mrverdant13 opened 1 year ago

mrverdant13 commented 1 year ago

Hey there! First, thanks for this useful package!!!

I am trying to display a custom widget with the error details of a FormArray to which a validator has been added with setValidators as follows:

formArray.setValidators([
  ...formArray.validators,
  Validators.delegate(someValidationLogic),
]);

I've tried with ReactiveValueListenableBuilder, ReactiveFormArray and ReactiveStatusListenableBuilder, but none of them seem to update the UI reactively. The only time when the UI seem to be updated is when the FormArray internal controls list get updated.

I am not completely sure that's a bug or if that is the expected behaviour.

joanpablo commented 1 year ago

Hi @mrverdant13,

ReactiveStatusListenableBuilder is the one that will rebuild each time the status of the FormArray changes (i.e. INVALID, VALID, PENDING, DISABLED).

The title of the issue is about showing the errors of the FormArray, but later on in the description of the issue you mention that the ReactiveStatusListenableBuilder is not working as expected.

Would you mind to elaborate a bit more on the issue, or show us a simple code example to understand what you are trying to accomplish? Thanks

mrverdant13 commented 1 year ago

Thanks for the quick response, @joanpablo !

More context here. I am using a nested setup for my form.

My Implementation

```dart import 'dart:async'; import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:reactive_forms/reactive_forms.dart'; class TestForm extends StatelessWidget { const TestForm({ super.key, }); @override Widget build(BuildContext context) { return ReactiveFormBuilder( form: () { final expectedTotalValueFormControl = FormControl( validators: [ Validators.required, Validators.min(0), ], ); final expectedTotalValueFormGroup = FormGroup({ 'expectedTotalValue': expectedTotalValueFormControl, }); final valuesFormArray = FormArray( [], validators: [ Validators.required, Validators.minLength(1), ], ); final valuesFormGroup = FormGroup({ 'array': valuesFormArray, }); final globalFormGroup = FormGroup({ 'expected': expectedTotalValueFormGroup, 'actual': valuesFormGroup, }); return globalFormGroup; }, child: const TestFormBody(), builder: (context, form, child) { return child!; }, ); } } class TestFormBody extends StatefulWidget { const TestFormBody({ super.key, }); @override State createState() => _TestFormBodyState(); } class _TestFormBodyState extends State { late final StreamSubscription subscription; static const jsonEncoder = JsonEncoder.withIndent(' '); @override void initState() { super.initState(); Future(() async { final formGroup = ReactiveForm.of(context, listen: false)! as FormGroup; subscription = formGroup.control('expected.expectedTotalValue').valueChanges.listen( (expectedTotalValue) { if (expectedTotalValue is! int) return; final valuesFormArray = formGroup.control('actual.array') as FormArray; final validators = valuesFormArray.validators; valuesFormArray.setValidators( [ ...validators, Validators.delegate( (_) { final actualTotalValue = valuesFormArray.controls.map( (control) { if (control is! FormGroup) return 0; final value = control.control('value').value; if (value is! num) return 0; return value; }, ).sum; print( 'actualTotalValue: $actualTotalValue - value: $expectedTotalValue', ); if (actualTotalValue == expectedTotalValue) return null; return { ValidationMessage.mustMatch: { 'expected': expectedTotalValue, 'actual': actualTotalValue, }, }; }, ), ], autoValidate: true, ); }, ); return subscription.cancel; }); } @override void dispose() { subscription.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final formGroup = ReactiveForm.of(context, listen: false)! as FormGroup; return Column( children: [ Padding( padding: const EdgeInsets.all(15), child: ReactiveTextField( decoration: const InputDecoration( labelText: 'Expected Total Value', ), formControlName: 'expected.expectedTotalValue', inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], ), ), const Divider(), Column( children: [ ReactiveFormArray( formArrayName: 'actual.array', builder: (context, formArray, child) { final groups = formArray.controls; return Column( children: [ for (final group in groups) if (group is FormGroup) ReactiveForm( key: ValueKey(group), formGroup: group, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 15, vertical: 7.5, ), child: Row( children: [ Expanded( child: ReactiveTextField( decoration: const InputDecoration( labelText: 'Value', ), formControlName: 'value', inputFormatters: [ FilteringTextInputFormatter.digitsOnly, ], keyboardType: TextInputType.number, ), ), const SizedBox(width: 15), IconButton( icon: const Icon(Icons.delete), onPressed: () { formArray.removeAt(groups.indexOf(group)); }, ), ], ), ), ), ], ); }, ), ReactiveStatusListenableBuilder( formControlName: 'actual.array', builder: (context, formArray, child) { if (!formArray.touched || formArray.valid) { return const SizedBox.shrink(); } return Text( jsonEncoder.convert(formArray.errors), style: const TextStyle( color: Colors.red, ), ); }, ), ReactiveValueListenableBuilder( formControlName: 'actual.array', builder: (context, formArray, child) { if (!formArray.touched || formArray.valid) { return const SizedBox.shrink(); } return Text( jsonEncoder.convert(formArray.errors), style: const TextStyle( color: Colors.green, ), ); }, ), ReactiveFormArray( formArrayName: 'actual.array', builder: (context, formArray, child) { if (!formArray.touched || formArray.valid) { return const SizedBox.shrink(); } return Text( jsonEncoder.convert(formArray.errors), style: const TextStyle( color: Colors.blue, ), ); }, ), ElevatedButton.icon( onPressed: () { final formArray = formGroup.control('actual.array'); if (formArray is! FormArray) return; formArray.add( FormGroup({ 'value': FormControl( validators: [ Validators.required, Validators.min(0), ], ), }), ); }, icon: const Icon(Icons.add), label: const Text('Add group'), ), ElevatedButton.icon( onPressed: () { final errorsJson = jsonEncoder.convert(formGroup.errors); print(errorsJson); formGroup.markAllAsTouched(); }, icon: const Icon(Icons.check), label: const Text('Validate'), ), ], ), ], ); } } ```

Output when tapping on the Validate button

This proves that the error in the array is actually present. ```json { "expected": { "expectedTotalValue": { "required": true, "min": { "min": 0, "actual": null } } }, "actual": { "array": { "minLength": { "requiredLength": 1, "actualLength": 0 } } } } ```

Screenshots

**Initial state** ![Screenshot_20230825-125457](https://github.com/joanpablo/reactive_forms/assets/42245236/c97552c9-a2a2-4ad7-9f79-d742de727856) **Actual state after tapping on the `Validate` button** ![Screenshot_20230825-125522](https://github.com/joanpablo/reactive_forms/assets/42245236/5e913c70-a8be-40df-99bc-154f26667c22) **Expected state after tapping on the `Validate` button** (which is properly displayed after a hot reload) > Note: The JSONs is used for demonstration purposes only. They would be replaced with an error widget. ![Screenshot_20230825-125532](https://github.com/joanpablo/reactive_forms/assets/42245236/49dd1f0d-af7c-44e6-bf9c-79c587540765)

joanpablo commented 1 year ago

Hi @mrverdant13,

Thanks for the example. I'm going to take a look to understand what's going on.

joanpablo commented 1 year ago

Hi @mrverdant13,

I have tested your example and it works correctly, as expected. The errors of the Form Array are displayed correctly.

There are a lot of things to improve and simplify in that code that you posted here. But it works correctly.

I recommend you to use dirty or pristine if instead of touched if you want to see the errors sooner.

if (formArray.pristine || formArray.valid) {
   return const SizedBox.shrink();
}

this is the complete code (same as yours but simplified):

import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:reactive_forms/reactive_forms.dart';
import 'package:reactive_forms_example/sample_screen.dart';

class TestForm extends StatelessWidget {
  const TestForm({
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return SampleScreen(
      body: ReactiveFormBuilder(
        form: () {
          final expectedTotalValueFormControl = FormControl<int>(
            validators: [
              Validators.required,
              Validators.min(0),
            ],
          );

          final expectedTotalValueFormGroup = FormGroup({
            'expectedTotalValue': expectedTotalValueFormControl,
          });

          final valuesFormArray = FormArray(
            [],
            validators: [
              Validators.required,
              Validators.minLength(1),
            ],
          );

          final valuesFormGroup = FormGroup({
            'array': valuesFormArray,
          });

          final globalFormGroup = FormGroup({
            'expected': expectedTotalValueFormGroup,
            'actual': valuesFormGroup,
          });

          return globalFormGroup;
        },
        builder: (context, form, child) {
          return const TestFormBody();
        },
      ),
    );
  }
}

class TestFormBody extends StatefulWidget {
  const TestFormBody({
    super.key,
  });

  @override
  State<TestFormBody> createState() => _TestFormBodyState();
}

class _TestFormBodyState extends State<TestFormBody> {
  late final StreamSubscription<dynamic> subscription;
  static const jsonEncoder = JsonEncoder.withIndent('  ');

  @override
  void initState() {
    super.initState();
    Future(() async {
      final formGroup = ReactiveForm.of(context, listen: false)! as FormGroup;
      subscription =
          formGroup.control('expected.expectedTotalValue').valueChanges.listen(
        (expectedTotalValue) {
          if (expectedTotalValue is! int) return;
          final valuesFormArray =
              formGroup.control('actual.array') as FormArray;
          final validators = valuesFormArray.validators;
          valuesFormArray.setValidators(
            [
              ...validators,
              Validators.delegate(
                (_) {
                  num actualTotalValue = 0;
                  if (valuesFormArray.controls.isNotEmpty) {
                    actualTotalValue = valuesFormArray.controls.map<num>(
                      (control) {
                        if (control is! FormGroup) return 0;
                        final value = control.control('value').value;
                        if (value is! num) return 0;
                        return value;
                      },
                    ).reduce((value, element) => value + element);
                  }
                  print(
                    'actualTotalValue: $actualTotalValue - value: $expectedTotalValue',
                  );
                  if (actualTotalValue == expectedTotalValue) return null;
                  return {
                    ValidationMessage.mustMatch: {
                      'expected': expectedTotalValue,
                      'actual': actualTotalValue,
                    },
                  };
                },
              ),
            ],
            autoValidate: true,
          );
        },
      );
      return subscription.cancel;
    });
  }

  @override
  void dispose() {
    subscription.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final formGroup = ReactiveForm.of(context, listen: false)! as FormGroup;
    return Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(15),
          child: ReactiveTextField<int>(
            decoration: const InputDecoration(
              labelText: 'Expected Total Value',
            ),
            formControlName: 'expected.expectedTotalValue',
            inputFormatters: [
              FilteringTextInputFormatter.digitsOnly,
            ],
          ),
        ),
        const Divider(),
        Column(
          children: [
            ReactiveFormArray(
              formArrayName: 'actual.array',
              builder: (context, formArray, child) {
                final groups = formArray.controls;
                return Column(
                  children: [
                    for (final group in groups)
                      if (group is FormGroup)
                        ReactiveForm(
                          key: ValueKey(group),
                          formGroup: group,
                          child: Padding(
                            padding: const EdgeInsets.symmetric(
                              horizontal: 15,
                              vertical: 7.5,
                            ),
                            child: Row(
                              children: [
                                Expanded(
                                  child: ReactiveTextField<int>(
                                    decoration: const InputDecoration(
                                      labelText: 'Value',
                                    ),
                                    formControlName: 'value',
                                    inputFormatters: [
                                      FilteringTextInputFormatter.digitsOnly,
                                    ],
                                    keyboardType: TextInputType.number,
                                  ),
                                ),
                                const SizedBox(width: 15),
                                IconButton(
                                  icon: const Icon(Icons.delete),
                                  onPressed: () {
                                    formArray.removeAt(groups.indexOf(group));
                                  },
                                ),
                              ],
                            ),
                          ),
                        ),
                  ],
                );
              },
            ),
            ReactiveStatusListenableBuilder(
              formControlName: 'actual.array',
              builder: (context, formArray, child) {
                if (formArray.pristine || formArray.valid) {
                  return const SizedBox.shrink();
                }
                return const Text(
                  'Array invalid',
                  style: TextStyle(
                    color: Colors.red,
                  ),
                );
              },
            ),
            ElevatedButton.icon(
              onPressed: () {
                final formArray = formGroup.control('actual.array');
                if (formArray is! FormArray) return;
                formArray.add(
                  FormGroup({
                    'value': FormControl<int>(
                      validators: [
                        Validators.required,
                        Validators.min(0),
                      ],
                    ),
                  }),
                );
              },
              icon: const Icon(Icons.add),
              label: const Text('Add group'),
            ),
            ElevatedButton.icon(
              onPressed: () {
                final errorsJson = jsonEncoder.convert(formGroup.errors);
                print(errorsJson);
                formGroup.markAllAsTouched();
              },
              icon: const Icon(Icons.check),
              label: const Text('Validate'),
            ),
          ],
        ),
      ],
    );
  }
}