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

Creating custom reactive form field better api #181

Closed sokolej79 closed 3 years ago

sokolej79 commented 3 years ago

Creating custom reactive form field is is a little clumsy. Flutter promotes composition over inheritance as all software does. For new field we must extends statefullWidget ReactiveFormField<ModelDataType, ViewDataType> and create widget in builder in constructor super method. It will be better to create some kind of extendible base form field widget with abstract method build and some decorator pattern and create new custom field something like that :

class TestReactiveField extends StatelessWidget /Or StatefullWidget/ { final FieldConfig fieldConfig; TestReactiveField({required this.fieldConfig, Key? key}) : super(key: key);

@override Widget build(BuildContext context) { return BaseReactiveField<String,String>( // state is model with field state and if required additional properties fieldConfig:fieldConfig, child:(state) =>MyCustomWidget(state:state), ); } } Or maybe replaced ReactiveFormField with hook widget to mantain state and custom field could extends hook widget and pass child. Another problem with ReactiveFormField is that is not easy "extandable" to add new properties and functions to base field that can be inherited from every custom field. Otherwise I would like to thank you for great package.

vasilich6107 commented 3 years ago

@sokolej79 Hi Here is a basic example of creating custom widget https://github.com/joanpablo/reactive_forms/blob/master/lib/src/widgets/reactive_switch.dart

Could you write a demo code which clearly describes your idea and makes the experience less clumsy?

sokolej79 commented 3 years ago

Little background first, For demo code I still thinking for right design approach. We use custom dynamic forms with the reactive_forms package. All data for form and fields are obtained from the server as json: FormOptions, FieldConfig, templateOptions.... Then we dynamicly build form and fields. How to add additional state properties to an existing ReactiveFormField widget? We need some Base form field that accepts models: fieldConfig, formOptions, templateOptions and everything else must work the same as ReactiveFormField does. This base field would be extended from each custom form field with different UI and settings (text, select...) and listened to control state, value, fielddConfig settings if any of them changed, then must rebuild field. Another important thing are dependend fields, that trigger some logic if one field value or other properties changes.

kuhnroyal commented 3 years ago

I agree that this feels a bit "clumsy" as you say, and in Flutter it is better to wrap in another widget than to extend. This can probably be refactored but would be a big breaking change, main problems being the handling of focus nodes and text controllers.

sokolej79 commented 3 years ago

I was thinking base field with Hooks, but still don't know if it is the right approach.

Pseudo code: class ReactiveField extends HookWidget { final FieldConfig fieldConfig; final String formControlName; final ValidationMessagesFunction? validationMessages; final ControlValueAccessor<ModelDataType, ViewDataType>? valueAccessor; final ShowErrorsFunction? showErrors;

ReactiveField( {Key? key, required this.formControlName, this.valueAccessor, this.showErrors, this.validationMessages, required this.fieldConfig, : super(key: key);

@override Widget build(BuildContext context) { final state = useFieldHook(fieldConfig, valueAccessor, showErrors, validationMessages); return TextField( onChange:(val)=>state.value=val, ... } }

Hook useFieldHook would mantain field state and changes and then some kind of factory to create form fields with input settings.

vasilich6107 commented 3 years ago

@sokolej79 I do not see much difference between pseudo code and current approach

We have to do the same things: + extend from custom class ReactiveFormField vs HookWidget + pass through all props + map reactive_forms control elements to the widget

Maybe I'm missing something

sokolej79 commented 3 years ago

Yes maybe will need something better, but difference is that with hook approach (can be something else), we can create custom widget in build method and not extending StatefullWidget and build widget in constructor in super call. In constructor builder you cannot access class methods only static or extend state. This is very wrong approach.

sokolej79 commented 3 years ago

For now I have extended ReactiveFormField and added custom properties and methods on state and every custom field extends this new base field widget. But I had to create all 17 custom field widgets we with this new base class and that is not good. I couldn't add additional properties and methods in some other way. And I dont control the rebuilds.

vasilich6107 commented 3 years ago

Could you give me quick example of your implementation and usage. Maybe I'll suggest you something

sokolej79 commented 3 years ago

Currently I share field logic with inheritence in base Field model. In sample field BarcodeField I can't use private Widget methods because of constructor and super, I know can be used with decomposition smaller widget with inputs and callback functions but sometimes I prefer private methods and is not possible, to add methods on state as below is little strange.

Another question, maybe not for these thread, is where and how to listen for dependent field provided in FieldConfig settings from server. Expressions like: if field with key changes value show/hide another field or autocomplete selected first value depends on second autocomplete (company and person). All expressions provided in settings from server. Listen for expression, field changes in Form controller or in Field? It is some how difficult if you have multiple dependent fields with multiple expressions from settings. Listen each field separately or all in form valeuchanges and them on expression trigger some function that changes form field properties and rebuild? Forgot to tell that FieldConfig, FormOptionsState and TemplateOptions models in controller can change at runtime and must be updated fields accordingly.

Some current code and flow: 1.) On top I have FormController with Getx state management, which provides form data and schema, settings from server to build Formgroup and controls or FormGroup with nested Formgroups if is Form in tabs also has tracking form state changes, form mode insert, update, delete, submit...

2.) Fields

typedef ReactiveFieldBuilder<T, K> = Widget Function(ReactiveFieldState<T, K> field);

class ReactiveField<ModelDataType, ViewDataType> extends ReactiveFormField<ModelDataType, ViewDataType> {
  final FieldConfig fieldConfig;
  final ReactiveFieldBuilder<ModelDataType, ViewDataType> builder;
  final ValidationMessagesFunction? validationMessage;
  final ShowErrorsFunction? showError;

  ReactiveField({required this.fieldConfig, required this.builder, this.validationMessage, this.showError})
      : super(
            formControlName: fieldConfig.key,
            validationMessages: validationMessage,
            showErrors: showError,
            builder: (field) {
              return Visibility(visible: fieldConfig.visible, child: builder(field as ReactiveFieldState<ModelDataType, ViewDataType>));
            });

  @override
  ReactiveFieldState<ModelDataType, ViewDataType> createState() =>
      ReactiveFieldState<ModelDataType, ViewDataType>(fieldConfig: fieldConfig);
}

// State for base reactive form Field
class ReactiveFieldState<ModelDataType, ViewDataType> extends ReactiveFormFieldState<ModelDataType, ViewDataType> {
  final FieldConfig fieldConfig;

  ReactiveFieldState({required this.fieldConfig});

  bool get readOnly => (fieldConfig.templateOptions?.readonly ?? false) || control.disabled;

  bool get isTouchedAndInvalid => control.touched && control.invalid && control.hasErrors;

// Other shared props and methods that uses field control state
}

// Sample custom field:
class BarcodeScannerFormField extends ReactiveField<dynamic, String> {
  BarcodeScannerFormField({required FieldConfig fieldConfig})
      : super(
            fieldConfig: fieldConfig,
            showError: fieldConfig.hasValidators ? (control) => showErrorMessage(control, fieldConfig.key) : null,
            validationMessage: fieldConfig.hasValidators ? (control) => getValidationMessage(control, fieldConfig.key) : null,
            builder: (field) {
              final state = field as BarcodeScannerFormFieldState..setFocusNode();
              return TextField(
                onChanged: state.didChange,
                enabled: state.control.enabled,
                autocorrect: false,
                controller: state.textController,
                focusNode: state.focusNode,
                readOnly: state.readOnly,
                decoration: InputDecoration(
                    enabled: !state.readOnly,
                    labelText: fieldConfig.templateOptions?.label ?? '',
                    hintText: fieldConfig.templateOptions?.placeholder,
                    labelStyle: TextStyle(color: Theme.of(state.context).primaryColor),
                    border: OutlineInputBorder(),
                    filled: false,
                    errorText: state.errorText,
                    contentPadding: EdgeInsets.all(16),
                    suffixIcon: (state.control.value == null || state.control.value.isEmpty || !state.control.valid)
                        ? IconButton(
                            onPressed: () async {
                              await state.scanBarcode();
                            },
                            icon: Icon(Icons.scanner),
                          )
                        : SizedBox(
                            width: 100,
                            child: Row(
                              mainAxisAlignment: MainAxisAlignment.end,
                              children: [
                                IconButton(
                                  onPressed: () async {
                                    await state.scanBarcode();
                                  },
                                  icon: Icon(Icons.scanner),
                                ),
                                IconButton(
                                  onPressed: () {},
                                  icon: Icon(Icons.done),
                                ),
                              ],
                            ),
                          )),
                keyboardType: TextInputType.text,
              );
            });

  @override
  BarcodeScannerFormFieldState createState() => BarcodeScannerFormFieldState(fieldConfig: fieldConfig);
}

// Extends InputFormFieldState already used in custom InputField and provide only additional methods
class BarcodeScannerFormFieldState extends InputFormFieldState {
  BarcodeScannerFormFieldState({required FieldConfig fieldConfig}) : super(fieldConfig: fieldConfig);

  Future scanBarcode() async {
    try {
      final barcodeScanRes = await FlutterBarcodeScanner.scanBarcode('#ff6666', 'Abandon', true, ScanMode.QR);
      if (barcodeScanRes.isEmpty || barcodeScanRes.trim() == '-1') {
        return;
      }
      updateBarcodeValue(barcodeScanRes.trim());
    } catch (e, trace) {
      logError('Error read barcode', '$e', trace);
      toastError('Error read barcode. $e');
      updateBarcodeValue('');
    }
  }

  void updateBarcodeValue(dynamic value) {
    try {
      if (!control.dirty) {
        control.markAsDirty(emitEvent: false);
      }
      control.updateValue(value);
      if (!control.touched) {
        control.markAsTouched();
      }
    } catch (e, trace) {
      logError('BarcodeField - updateBarcodeValue ${fieldConfig.key}', e, trace);
    }
  }
}

// Some models for field and form settings
class FieldConfig {
  final String id; // unique auto generated if not provided
  final String key;
  final int? groupId;
  final FieldType type;
  final dynamic defaultValue;
  final dynamic initallValue;
  final TemplateOptions? templateOptions;
  final Map<String, String>? validationMessages;
  final List<Map<String, dynamic>? Function(AbstractControl value)>? validators;
  final List? asyncValidators;
  final List? fieldGroup;
  final FieldConfig? fieldArray;
  final Map<String, dynamic>? expressionProperties;
  final bool hidden;
}

// field template options

class TemplateOptions {
  final String? label;
  final String? placeholder;
  final bool disabled;
  final bool readonly;
  final bool isRequired;
  final List<SelectOption?>? options;
  final Settings? settings;
  final Function? onFocus;
  final Function? onChange;
  // for container field (tabs..)
  final String? icon;
  final int? selectedIndex;
  final int? order;
}

// Settings for form and shared fields from server json:
class FormOptionsState {
  final String? queryVariable;
  final String? dataQuery;
  final String? insertForm;
  final String? updateForm;
  final String? deleteForm;
  final DataField? dataField;
  final Map<String, dynamic>? selectCustomFileds;
}

// Field factory creates field widget:
class FieldFactory {
  static Widget fromType({required FieldConfig fieldConfig}) {
    switch (fieldConfig.type) {
      case FieldType.text:
      case FieldType.numeric:
      case FieldType.textarea:
      case FieldType.decimal:
        return InputFormField(fieldConfig: fieldConfig);

      case FieldType.datetime:
        return DateTimeFormField(fieldConfig: fieldConfig);
      //... other fields
      default:
        return InvalidFormField(fieldConfig: fieldConfig);
    }
  }
}

enum FieldType {
  numeric,
  decimal,
  text,
  textarea,
  select,
  multiselect,
  date,
  time,
  datetime,
  datetimenow,
  dateRange,
  autocomplete,
  autocompletemultiselect,
  radiogroup,
  checkbox,
  checkboxgroup,
  switchfield,
  attachment,
  barcodeScanner,

  unsupportedType,
  errorField,
  hiddenField
}

enum FormMode {insert, update, readOnly, delete }
kuhnroyal commented 3 years ago

Please format your code with tripple backticks ```dart around the blocks.

vasilich6107 commented 3 years ago

Wow ) Long read)

sokolej79 commented 3 years ago

I tried some simple sample test with hooks and is working. Detects error messages, control state, touched, if needed value changed... Parameters and output state can be anything desired and controlled.

  final FieldConfig fieldConfig;
  ReactiveTextField({required this.fieldConfig}) : super();

  @override
  Widget build(BuildContext context) {
    final state = useFieldHook<String, String>(
        fieldConfig); // can be any other parameters inputs

    return TextField(
      onTap: state.control.markAsTouched,
      onChanged: (val) => state.changeValue(val),
      decoration: InputDecoration(
          errorText: state.errorText,
          labelText: fieldConfig.templateOptions!.label,
          hintText: fieldConfig.templateOptions!.placeholder),
    );
  }
}
sokolej79 commented 3 years ago

Closing.