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 89 forks source link

Validation Messages not shown on arrays #421

Open brunodmn opened 1 year ago

brunodmn commented 1 year ago

Trying to validate my array with custom validator, it works as form.valid state toggle. But validation message not shows near ReactiveFormArray nor its children. Thru documentation, was not clear if I had to do something additional to make this work.

I am using getx as state managment lib (this sample basic separates logic from ui)..code bellow

Controller import 'package:get/get.dart'; import 'package:reactive_forms/reactive_forms.dart'; class Contact { final String email; final String name; Contact({required this.email, required this.name}); } class _EmptyAdresses extends Validator { const _EmptyAdresses() : super(); @override Map? validate(AbstractControl control) { final emails = (control as FormArray).value ?? []; if (emails.isEmpty) { return {'emptyAddressee': true}; } if (emails.any((isSelected) => isSelected ?? false)) { return null; } return {'emptyAddressee': true}; } } class HomeController extends GetxController { final _isLoading = true.obs; get isLoading => _isLoading.value; final form = fb.group({ 'name': '', 'email': [''], 'selectedContacts': fb.array([], [const _EmptyAdresses()]) }); @override void onReady() { 'onReady...'.printInfo(); _init(); super.onReady(); } Future> getAllContacts() async { await 1.delay(); return [ Contact(email: "teste1gmail.com", name: "teste1"), Contact(email: "teste2gmail.com", name: "teste2") ]; } Future> getMyContacts() async { await 1.delay(); return [ Contact(email: "teste1gmail.com", name: "teste1"), ]; } final contacts = [].obs; _init() async { _isLoading(true); await 1.delay(); contacts.value = await getAllContacts(); final selectedContacts = await getMyContacts(); form.value = { 'name': 'John Doe', 'email': 'john@gmail.com', 'selectedContacts': contacts .map((contact) => selectedContacts.indexWhere( (selectedContact) => selectedContact.email == contact.email) > -1) .toList() }; _isLoading(false); } save() { contacts.length.printInfo(); form.value.printInfo(); } remove(Contact contact) { final selectedContacts = form.control('selectedContacts') as FormArray; final index = contacts.indexOf(contact); contacts.removeAt(index); selectedContacts.removeAt(index); } add() { final selectedContacts = form.control('selectedContacts') as FormArray; contacts.add(Contact( email: 'email${contacts.length + 1}@gmail.com', name: 'Contact${contacts.length + 1}')); selectedContacts.add(FormControl(value: true)); } }
Page import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:reactive_forms/reactive_forms.dart'; import 'controller.dart'; class HomePage extends GetView { const HomePage({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text( 'HomePage', ), actions: [ IconButton( onPressed: () => controller.save(), icon: const Icon(Icons.save)) ]), body: SafeArea(child: SingleChildScrollView( child: Obx(() { print('rebuilt'); // if (controller.isLoading) { // return const Center( // child: CircularProgressIndicator(), // ); // } return ReactiveForm( formGroup: controller.form, child: Column(children: [ if (controller.isLoading) const SizedBox( height: 40, child: LinearProgressIndicator(), ), ReactiveTextField( formControlName: "name", decoration: const InputDecoration(labelText: 'name'), ), ReactiveTextField( formControlName: "email", decoration: const InputDecoration(labelText: 'email'), ), ReactiveStatusListenableBuilder( formControl: controller.form, builder: (c, f, w) { final form = f as FormGroup; return Text('Is form valid? ${form.valid}'); }), ReactiveFormArray( formArrayName: 'selectedContacts', builder: (context, formArray, child) => Column(children: [ ...controller.contacts.map((contact) { return Row( children: [ Expanded( child: ReactiveCheckboxListTile( showErrors: (control) => control.invalid && control.touched && control.dirty, // the use of a Key here, is extremely important key: ValueKey(contact.email), formControlName: controller.contacts .indexOf(contact) .toString(), title: Text(contact.email), ), ), IconButton( onPressed: () { controller.remove(contact); }, icon: const Icon(Icons.delete)), ], ); }).toList(), IconButton( onPressed: () { controller.add(); }, icon: const Icon(Icons.add)), ]), ), ]), ); }), ))); } }

Most important is below widget..is anything missing to this shows validation messages?

 ReactiveFormArray<bool>(
                  formArrayName: 'selectedContacts',
                  builder: (context, formArray, child) => Column(children: [
                    ...controller.contacts.map((contact) {
                      return Row(
                        children: [
                          Expanded(
                            child: ReactiveCheckboxListTile(
                              showErrors: (control) =>
                                  control.invalid &&
                                  control.touched &&
                                  control.dirty,
                              // the use of a Key here, is extremely important
                              key: ValueKey(contact.email),
                              formControlName: controller.contacts
                                  .indexOf(contact)
                                  .toString(),
                              title: Text(contact.email),
                            ),
                          ),
                          IconButton(
                              onPressed: () {
                                controller.remove(contact);
                              },
                              icon: const Icon(Icons.delete)),
                        ],
                      );
                    }).toList()
joanpablo commented 1 year ago

Hi @brunodmn,

The ReactiveFormArray by itself does not show any error since it is only a widget that helps you listen for changes in a FormArray.

The children on the other way should be able to display its own errors. The code you sent above is using a Checkbox, and coincidentally Checkboxes are some of the few Flutter controls that doesn't have error messages (you will have to add an extra widget to display messages). For the purpose of testing try to use a ReactiveTextField, it should display the error messages.

Let me know if there is anything else I can help with.

Thanks

brunodmn commented 1 year ago

Hi @joanpablo ,

Thank your for quick reply and for your package, I loved using reactive forms!

In my original project I am using ReactiveTextField still with no success, below I added snipeds of the validator and the form itself, I can move them to minimum reproducible project if necessary and send it here.

Form group:

fb.group({
'bannerUrls':
            fb.array<String>([], [const IsUrlListValid()])
}

My Validator:

class IsUrlListValid extends Validator<dynamic> {
  const IsUrlListValid() : super();

  @override
  Map<String, dynamic>? validate(AbstractControl<dynamic> control) {
    final List<String?> strList = control.value ?? [];
    final isAnyEmpty = strList.any((str) => (str ?? '').isEmpty);
    if (isAnyEmpty) {
      return {'required': true};
    }
    final isAnyInvalid = strList.any((str) => !(str ?? '').isURL);

    if (isAnyInvalid) {
      return {'invalidUrl': true};
    }
    return null;
  }
}

My Widget:

  ReactiveStatusListenableBuilder(
                                formControl: controller.form,
                                builder: (context, status, widget) {
                                  final form = status as FormGroup;

                                  return Text('${form.valid}');
                                },
                              ),
                              ReactiveFormArray<String>(
                                key: UniqueKey(),
                                formArrayName: 'bannerUrls',
                                builder: (context, formArray, child) {
                                  return Column(
                                    children: [
                                      ...controller.bannerUrls
                                          .map((url) => ReactiveTextField(
                                                key: ValueKey(url),
                                                formControlName: controller.bannerUrls
                                                    .indexOf(url)
                                                    .toString()
                                              ))
                                    ],
                                  );
                                },
                              ),

The logic seems to work as ReactiveStatusListenableBuilder returns the correct state while the field is being changed. I also tested similar validator for a field instead of a list and works properly showing all messages.

For instance, I use a pretty nested fb, on original code this array is named 'feature.marketing.bannerUrls'. I am now extracting the controls over nested forms and checking valid state, such as:

 ReactiveStatusListenableBuilder(
                                formControl: controller.form,
                                builder: (context, status, widget) {
                                  final form = status as FormGroup;
                                  final marketing =
                                      form.control('feature.marketing')
                                          as FormGroup;

                                  final aryControl =
                                      marketing.control('bannerUrls')
                                          as FormArray<String?>;
                                  // print(aryControl.controls.length);
                                  for (var control in aryControl.controls) {
                                    final c = control as FormControl<String?>;
                                    print(c.value.toString() +
                                        ' is valid? ${c.valid}');
                                  }
                                  return Text('${marketing.valid}');
                                },
                              ),

On the upper code,aryControl.controls are always returning "is valid? true" even though the marketing.valid is false. I am not editing other fields on this marketing fb that can possibly change its state.

Below is code returns the expected result:

form.valueChanges.listen((event) {
     final hasError = form.hasError("required", 'feature.marketing.bannerUrls');
     print('$hasError');
    });

Seems like the valid state is not being passed to children of the FormArray (nested ReactiveTextFields) which might not trigger the message.

Is there something I did wrong on the code?

brunodmn commented 12 months ago

@joanpablo , how can I access validation message, so I could use a workaround using ReactiveStatusListenableBuilder?

Something like bellow code, but using the message registered on ReactiveFormConfig.

 ReactiveStatusListenableBuilder(
                                  key: UniqueKey(),
                                  formControl: controller.form
                                      .control("feature.marketing.bannerUrls"),
                                  builder: (context, state, widget) {
                                    final f = state as FormArray<String?>;
                                    if (f.errors.isNotEmpty) {
                                      final error = f.errors.entries.first;
                                      if (error.key == 'alreadyExists') {
                                        return Row(
                                          children: [
                                            Text('This register already exists',
                                                style: context
                                                    .textTheme.bodyMedium
                                                    ?.copyWith(
                                                        color: context
                                                            .theme
                                                            .colorScheme
                                                            .error)),
                                          ],
                                        );
                                      }
                                    }
                                    return const SizedBox.shrink();
                                  }),
joanpablo commented 12 months ago

Hi @brunodmn I will take a look at your code to understand the issue and let you know.

joanpablo commented 12 months ago

BTW, I would not use key: ValueKey(url) as key of the ReactiveTextField instead I would use something like Object key using the control as object. Just an observation.

UsamaKarim commented 2 months ago

BTW, I would not use key: ValueKey(url) as key of the ReactiveTextField instead I would use something like Object key using the control as object. Just an observation.

Thanks, It solved my problem while delete nested FormGroup in FormArray