reactive_forms icon indicating copy to clipboard operation
reactive_forms copied to clipboard

[Question] How to reactively display `FormArray` errors

Open mrverdant13 opened this issue 2 years ago • 4 comments

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.

mrverdant13 avatar Aug 24 '23 18:08 mrverdant13

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

joanpablo avatar Aug 24 '23 19:08 joanpablo

Thanks for the quick response, @joanpablo !

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

  • This form has a nested form-group with only a form-array that can be used to dynamically add sub-form-groups.
    All of those form-groups include a control for an integer value.
  • This form also has a nested form-group with only a form control that can be used to set the expected total sum of the values in the form-array.
My Implementation

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<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;
      },
      child: const TestFormBody(),
      builder: (context, form, child) {
        return child!;
      },
    );
  }
}

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(
                (_) {
                  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<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.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<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'),
            ),
          ],
        ),
      ],
    );
  }
}
Output when tapping on the Validate button

This proves that the error in the array is actually present.

{
  "expected": {
    "expectedTotalValue": {
      "required": true,
      "min": {
        "min": 0,
        "actual": null
      }
    }
  },
  "actual": {
    "array": {
      "minLength": {
        "requiredLength": 1,
        "actualLength": 0
      }
    }
  }
}
Screenshots

Initial state

Screenshot_20230825-125457

Actual state after tapping on the Validate button

Screenshot_20230825-125522

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

mrverdant13 avatar Aug 25 '23 18:08 mrverdant13

Hi @mrverdant13,

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

joanpablo avatar Aug 28 '23 01:08 joanpablo

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'),
            ),
          ],
        ),
      ],
    );
  }
}

joanpablo avatar Aug 31 '23 02:08 joanpablo