flutter_dynamic_forms icon indicating copy to clipboard operation
flutter_dynamic_forms copied to clipboard

Add ReactiveFormGroupRenderer dynamically?

Open ilubnon opened this issue 4 years ago • 4 comments

Hello @OndrejKunc, congratulations on the project!

Is it possible to duplicate (creating a new formGroup with default values) a formGroup dynamically?

In the ReactiveFormGroupRenderer class, I would like to do something like that, rendering with a button to dynamically add another formGroup

Something like that:

childrenWidgets.addAll(
  snapshot.data
      .whereType<FormElement>()
      .where((f) => f.isVisible)
      .map(
        (child) => renderer(child, context),
      ),
);
childrenWidgets.add(
  new IconButton(icon: const Icon(Icons.add_circle_outline), onPressed: () {
    Widget elementCopy = renderer(element, context);
    childrenWidgets.add(elementCopy);

    //Notify the root form to render again with the changed property value ???
  })
);

ilubnon avatar Jan 22 '20 04:01 ilubnon

Hi @ilubnon,

Thanks for the great question!

Some time ago we were also discussing this feature in our team. The concrete example might be that you have an address form group with multiple address fields like street, city, etc. and you would want to add another address on a button click. We were considering to define this behavior directly in the XML/JSON, but there were some challenges like:

  • How to describe what to copy in XML/JSON
  • How to generate a unique id for each component in the copy
  • How to use the expression language and reference another field from the same copy.

Although it is not impossible to solve, we decided to take a simple path for now: download the new form from the server and let the server add those fields.

That being said, if you don't need to have this behavior described in the XML/JSON, I think there is a possible solution to this problem on the client-side. However, it will not be implemented in the renderer as you are suggesting but rather in the model layer.

Specifically, there is a FormManager class which has a form property. This property is the root of the component tree, that is being rendered in the renderer classes. On your button click instead of rendering something, you would need to take a component in this tree, make a copy and add it back to the tree and only notify the view to re-render itself.

To make it simple I would start by making a special component, for example CopyContainer that would have list of children on its model and the copy button in the renderer. This copy button would take the element property of type CopyContainer, take the first child, make a copy and add it to the end of the list. You would then need to notify CopyContainer to re-render its children (for example by introducing ChildrenCount property that would be manually incremented on the copy and can be also subscribed in the renderer). There is a clone method on the form element which will help you to get the deep copy of the tree, but I think you would also need to change the id property of each element in the copy to avoid conflicts.

Because I find this problem very interesting I may prepare some example of what I just described in the following weeks.

OndrejKunc avatar Jan 22 '20 15:01 OndrejKunc

@OndrejKunc Thank you very much!

I took the liberty of creating some tests, as you mentioned. However, I just copied the ReactiveFormGroupRenderer by changing the class name and consequently the model and also the parser.

Something like that:

Model Group

import 'package:dynamic_forms/dynamic_forms.dart';
import 'package:flutter_dynamic_forms_components/flutter_dynamic_forms_components.dart';

class Group extends Container {
  static const String namePropertyName = 'name';

  Property<String> get nameProperty => properties[namePropertyName];

  set nameProperty(Property<String> value) =>
      registerProperty(namePropertyName, value);

  String get name => nameProperty.value;

  Stream<String> get nameChanged => nameProperty.valueChanged;

  @override
  FormElement getInstance() {
    return Group();
  }
}

Parser Group

import 'package:flutter_dynamic_forms_components/flutter_dynamic_forms_components.dart';
import 'package:dynamic_forms/dynamic_forms.dart';
import 'package:tovtec_dynamic_forms/models/groupModel.dart' as model;

class GroupParser<TGroup extends model.Group>
    extends ContainerParser<TGroup> {
  @override
  String get name => 'group';

  @override
  FormElement getInstance() => model.Group();

  @override
  void fillProperties(
      TGroup formGroup,
      ParserNode parserNode,
      Element parent,
      ElementParserFunction parser,
      ) {
    super.fillProperties(formGroup, parserNode, parent, parser);
    formGroup
      ..nameProperty = parserNode.getStringProperty(
        'name',
        defaultValue: ParserNode.defaultString,
        isImmutable: true,
      );
  }
}

Renderer Group

import 'package:dynamic_forms/dynamic_forms.dart';
import 'package:expression_language/expression_language.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_dynamic_forms/flutter_dynamic_forms.dart';
import 'package:tovtec_dynamic_forms/models/groupModel.dart' as model;

class ReactiveGroupRenderer extends FormElementRenderer<model.Group> {

  @override
  Widget render(
      model.Group element,
      BuildContext context,
      FormElementEventDispatcherFunction dispatcher,
      FormElementRendererFunction renderer) {
    return StreamBuilder<List<ExpressionProviderElement>>(
      initialData: element.children,
      stream: element.childrenChanged,
      builder: (context, snapshot) {
        List<Widget> childrenWidgets = [
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Text(
              element.name,
              style: TextStyle(color: Colors.grey),
            ),
          )
        ];
        childrenWidgets.addAll(
          snapshot.data.whereType<FormElement>().where((f) => f.isVisible).map(
                (child) => renderer(child, context),
          ),
        );

        childrenWidgets.add(new IconButton(
            icon: const Icon(Icons.add_circle_outline),
            onPressed: () {
//              Widget elementCopy = renderer(element, context);
//              childrenWidgets.add(elementCopy); ???
            }));
        return Column(children: childrenWidgets);
      },
    );
  }
}

ilubnon avatar Jan 22 '20 18:01 ilubnon

So I gave it a try and was able to find a solution. The working solution is for now in my branch ok/copy-container. I implemented a component called CopyContainer which is very similar to your Group component.

Here is what you need to do: 1) Add new behavior subject to your model, so you have an easy way to inform a view that new child was added:

  BehaviorSubject<int> changedSubject = BehaviorSubject<int>.seeded(0);
  Stream<int> get changedStream => changedSubject.stream;

Create a new event which will allow you to pass your component through the dispatcher:

class CopyFirstChildEvent extends FormElementEvent {
  final CopyContainer copyContainer; //Pass your Group component instead

  CopyFirstChildEvent(this.copyContainer);
}

In your Renderer wrap the outer StreamBuilder in another StreamBuilder listening to the changedStream you just added. Also, dispatch the CopyFirstChildEvent in your onPressed delegate. This is my renderer class but yours should be very similar.

class CopyContainerRenderer extends FormElementRenderer<CopyContainer> {
  @override
  Widget render(
      CopyContainer element,
      BuildContext context,
      FormElementEventDispatcherFunction dispatcher,
      FormElementRendererFunction renderer) {
    return StreamBuilder<int>(
      initialData: 0,
      stream: element.changedStream,
      builder: (context, itemCount) {
        return StreamBuilder<List<ExpressionProviderElement>>(
          initialData: element.children,
          stream: element.childrenChanged,
          builder: (context, snapshot) {
            return Column(
              children: [
                ...snapshot.data
                    .whereType<FormElement>()
                    .where((f) => f.isVisible)
                    .map(
                      (child) => renderer(child, context),
                    )
                    .toList(),
                IconButton(
                  icon: const Icon(Icons.add_circle_outline),
                  onPressed: () {
                    dispatcher(CopyFirstChildEvent(element));
                  },
                )
              ],
            );
          },
        );
      },
    );
  }
}

Finally, process your event in the same place you are processing ChangeValueEvent. You should also have your FormManager instance available in this place:

  void _onFormElementEvent(FormElementEvent event) {
    if (event is ChangeValueEvent) {
      _formManager.changeValue(
          value: event.value,
          elementId: event.elementId,
          propertyName: event.propertyName,
          ignoreLastChange: event.ignoreLastChange);
    }
    if (event is CopyFirstChildEvent) {
      var children = event.copyContainer.children;
      if (children.isEmpty) {
        return;
      }

      // Create copy of the first children
      var clonedRoot = children[0].clone(null);

      var clonedElements =
          getFormElementIterator<FormElement>(clonedRoot).toList();

      // Change id of each element in the cloned subtree
      for (var i = 0; i < clonedElements.length; i++) {
        var clonedElement = clonedElements[i];
        if (clonedElement.id == null) {
          continue;
        }
        clonedElement.id = "${clonedElement.id}_$i";
        _formManager.formElementMap[clonedElement.id] = clonedElement;
      }

      // Build expressions in the cloned subtree
      var clonedExpressions =
          getFormPropertyIterator<CloneableExpressionProperty>(clonedRoot);
      for (var expressionValue in clonedExpressions) {
        expressionValue.buildExpression(_formManager.formElementMap);
      }

      // Add subscriptions to existing expressions
      for (var expressionValue in clonedExpressions) {
        var elementsValuesCollectorVisitor =
            ExpressionProviderCollectorVisitor();
        expressionValue.getExpression().accept(elementsValuesCollectorVisitor);
        for (var sourceProperty
            in elementsValuesCollectorVisitor.expressionProviders) {
          (sourceProperty as Property).addSubscriber(expressionValue);
        }
      }

      (clonedRoot as FormElement).parentProperty = children[0].parentProperty;

      // Add back to the children list
      children.add(clonedRoot);

      // Notify view about the change
      event.copyContainer.changedSubject.add(children.length);
    }
  }

And that's it. I admit the code processing the CopyFirstChildEvent is not so easy to understand and probably should be moved to the library. Also, this solution is not so ideal, because you can't use expressions referencing the items in the block you are copying - we would need to extend expression language for this use case. However, you can reference any expressions outside this copy component. I should also add an API which allows user to emit new item in the default PropertyChanged stream so you don't have to define your own Stream for this.

Please let me know if this solved your problem or if you need any further help.

OndrejKunc avatar Feb 02 '20 23:02 OndrejKunc

This is pretty interesting, is there any chance this gets rebased and put into master? I'm looking to create a number of form groups based on a select field and this looks like it would be a good base.

elliots avatar Aug 18 '21 11:08 elliots