Search code examples
angulartypescriptangular-forms

How do you pass FormGroups and FormArrays into a recursive mechanism of deep nested components?


I'm building a micro app for editing JSON objects before saving them to my database. The JSON object comes from another micro app I made for parsing SVG code and creating a data object shaped like the interfaces bellow.

export interface OvaadSvgStyleProperty{
    property: string;
    setting: string;
  }

  export interface OvaadGraphicAttribute{
    attribute: string;
    setting: string;
    bind? : string;
  }

  export interface OvaadGraphicObject{
    element: string;
    selfClosing: boolean;
    attributes: OvaadGraphicAttribute[];
    styles?: OvaadSvgStyleProperty[];
    subElements?: OvaadGraphicObject[];
  }

  export interface ViewBoxParameters{
    x: string;
    y: string;
    width: string;
    height: string;
  }

  export interface OvaadSvgDataObject extends TitleCore{
      graphicId: string;
      viewBox: ViewBoxParameters;
      coreAttributes: OvaadGraphicAttribute[];
      coreStyles: OvaadSvgStyleProperty[];
      elements: OvaadGraphicObject[];
  }

If you pay attention to the OvaadGraphicObject interface you'll see the subElements property is of type OvaadGraphicObject[] which is to handle instances of <g> tags and other svg elements that can potentially nest things thousands of layers deep if that were needed for some reason.

I've learned through my struggles so far with Angular Forms that it likes for the form to be a singular monolithic thing so I decided to create a family of functions for generating the form and passing in the values from the JSON object and passing the completed FormGroup into my form with [formGroup]="myFormVar".

This provides the FormGroup shaped accordingly with all the values passed in as expected. If I treat the data like any other data and pass it into @Input()s everything breaks down and goes to where it needs to go, but unfortunately it breaks apart the communication within the form. You can view a stackblitz demo here to see the entire mechanism so far but for the sake of this post I'll be focusing on the elements aspect of the form which will pretty much crack the riddle of how to handle everything else.

To start, the function I use for creating the element FormGroup looks like this

export function CreateGraphicElementForm(data?: OvaadGraphicObject): FormGroup{
    let elementForm: FormGroup = new FormGroup({
      element     : new FormControl((data ? data.element    : ''), Validators.required),
      selfClosing : new FormControl((data? data.selfClosing : false), Validators.required),
      attributes  : new FormArray([]),
      styles      : new FormArray([]),
      subElements : new FormArray([])
    });

    if(data && data.attributes.length > 0){
      data.attributes.forEach((a: OvaadGraphicAttribute)=>{
        let attrArray: FormArray = elementForm.get('attributes') as FormArray;
        attrArray.push(CreateAttributeForm(a));
      });
    }

    if(data && data.styles.length > 0){
      data.styles.forEach((a: OvaadSvgStyleProperty)=>{
        let styleArray: FormArray = elementForm.get('styles') as FormArray;
        styleArray.push(CreateStylePropertyForm(a));
      });
    }

    if(data && data.subElements){
      data.subElements.forEach((a:OvaadGraphicObject)=>{
        let subElementArray: FormArray = elementForm.get('subElements') as FormArray;
        subElementArray.push(CreateGraphicElementForm(a));
      });

    }

    return elementForm as FormGroup;
  }

So within this FormGroup there's 2 FormControls and 3 FormArrays that need to be passed down into their respective components. The way I'm handling this part is with what I'm calling and Element List Component that takes an array of these FormGroups and is responsible for adding or deleting them. Then I have an ElementFormComponent which receives a FormGroup from the ElementListComponent. Then of course the ElementListComponent gets it's data from the parent component where the form is created which I call the SvgObjectFormComponent. Here's what the ElementListComponent and ElementFormComponent looks like.

ElementList.component.ts

export class ElementListComponent implements OnInit {

  @Input() ElementListData: FormArray;

  constructor() { }

  ngOnInit() {
  }

  addElement(): void{
    const newElement: FormGroup = CreateGraphicElementForm();
    let elementList: FormArray = <FormArray>this.ElementListData as FormArray;
    console.log(newElement);
    elementList.push(newElement);
  }

  deleteElement(item: number): void{
    let elementList:FormArray = this.ElementListData as FormArray;

    console.log(item);

    elementList.removeAt(item);
  }

}

ElementList.component.html

<section [formGroup]="ElementListData">


    <article *ngIf="!ElementListData">
        <p>loading</p>
    </article>

    <article *ngIf="ElementListData">

        <h5 *ngIf="ElementListData.controls.length === 0" class="articleText">no elements</h5>

        <section *ngIf="ElementListData.controls.length > 0">

            <article *ngFor="let item of ElementListData.controls; let i = index" class="list-grid">

                <p class="index-area articleText">{{i}}</p>


       <!-- I removed attempts at passing in data to avoid discussing things we already know are wrong-->
                <element-form-component class="component-area"></element-form-component>



                <article class="delete-area">
                    <button (click)="deleteElement(i)">delete</button>
                </article>


            </article>

        </section>

    </article>

    <article>
        <button (click)="addElement()">add</button>
    </article>

</section>


ElementForm.component.ts

export class ElementFormComponent implements OnInit, ControlValueAccessor {

  @Input() ElementFormData: FormGroup;

  constructor() { }

  ngOnInit() {}

}

ElementForm.component.html

<section [formGroup]="ElementFormData">
    <article class="text-control-section">
        <label class="control-label">
            element:
            <input type="text" class="text-control" formControlName="element" />
        </label>

        <label class="control-label">
            self closing:
            <input type="text" class="text-control" formControlName="selfClosing" />
        </label>
    </article>



    <!-- again I eliminated failed attempts at passing objects into components to reduce confusion -->
    <article>
        <h3>Attributes</h3>
        <attribute-list-component></attribute-list-component>
    </article>

    <article>
        <h3>Styles</h3>
        <style-list-component></style-list-component>
    </article>



    <section>

        <h3>Sub Elements</h3>

        <p *ngIf="ElementFormData.controls.subElements.length === 0">no sub elements</p>

        <article *ngFor="let item of ElementFormData.controls.subElements; let i = index" class="list-grid">

            <p class="index-area">{{i}}</p>


           <!-- this is where the recursive behavior begins if the element has nested elements -->
            <element-form-component class="component-area"></element-form-component>

            <article class="delete-area">
                <button>delete element</button>
            </article>

        </article>

    </section>




</section>

I've been trying everything from using [(ngModel)] and [FormControl] vs. formControlName vs. [formControlName] and tinkering with the ControlValueAccessor just to discover it's only meant for single FormControls. On another question I asked which was based around the ControlValueAccessor someone made the suggestion that I should add this to my component providers

viewProviders: [
   { provide: ControlContainer, useExisting: FormGroupDirective }

which eliminates the need for the ControlValueAccesssorallowing all the controls to be accessed, but that assumes I'm only using my component in one place in the form syncing to only one FormGroup when as you see from this example I need to figure out how to provide a recursive connection to the top of the component tree. How can this type of behavior be achieved with Angular Forms?

My stackblitz contains all the components, functions and a data object for demo as well as my most recent attempts at getting this behavior to work which was through the ControlValueAcessor. Someone please help me figure out how to accomplish this because Angular Forms is the one thing I haven't been able to get a grasp on the entire time I've been using the platform due to not knowing whatever it is I can't seem to find my way in front of.


Solution

  • I came across this article which covers how to accomplish this behavior by using ChangeDetectionStrategy.push on our components. We add it to our component like this

    @Component({
        selector       : 'element-list-component',
        templateUrl    : 'element-list.component.html',
        StyleUrls      : ['element-list.component.css'],
        // add change detection here
        changeDetection: ChangeDetectionStrategy.push
    })
    
    export class ElementListComponent {
    
        @Input() ElementListData : FormGroup;
    
        //.......
    }
    

    Then in our Component class we can use a regular @Input() to pass in the form object as we do with any other piece of data. In the parent component we need to pass in the object like this

    <section [formGroup]="FormDataVar">
    
        <element-list-component [ElementListData]="FormDataVar.controls.elements"></element-list-component
    
    </section>
    

    From there we connect the FormControls to our inputs with formControlName="yourControl" and everything stays synced to the top of the tree :).