angularangular-reactive-formsng-template

Angular Generic List Component with Dynamic Form Controls - `FormControlName` Issues


I'm building a generic Angular list component that integrates with forms using value accessors. I need to dynamically render form controls with custom theming inside this list. The following is a sand box example.

 <a-component>
  <ng-template let-name="name">
    <div *provideControlContainer>
      <div>{{ name }}</div>
      <div>
          <input [formControlName]="name">
      </div>
    </div>
  </ng-template>
</a-component>

And

<div [formGroup]="form">
  <input formControlName="name">
  <b-component name='alias' />
  <hr/>
  <ng-container
    *provideControlContainer
    [ngTemplateOutlet]="template"
    [ngTemplateOutletContext]="{ name: 'surname'}"
    />
</div>

The problem is that FormControlName only searches for parent ControlContainer instances within the host component, due to the @Host() decorator:

TypeScript

constructor(
  @Optional() @Host() @SkipSelf() parent: ControlContainer,
)

This prevents dynamically generated form controls within my list component's template (using ng-template) from correctly registering with the parent form.

I've tried injecting and proxying the parent ControlContainer, but this only works with component transclusion, not ng-template.

I'm also considering these solutions:

  1. Overriding the FormControlName directive to remove the @Host() limitation.
  2. Dynamically creating components using a builder function (which seems less ideal).

Are there existing solutions or best practices for this scenario? I'd prefer to use a template or projection approach if possible. Any suggestions or alternative approaches would be greatly appreciated. There is stackblitz to play with.

P.S. It was built with AI tools.


Solution

  • Collecting workspace informationSure, here is a summary of the four variants of attaching control to the control group in Angular, based on the files imported in main.ts.


    Summary of Attaching Control to Control Group in Angular

    In this project, there are four different approaches to attaching a control to a control group. Each approach is demonstrated in a separate file. Below is a summary of each approach:

    1. Using pass-container-instance.ts

    In this approach, the control is passed as an input to a child component, and the child component uses ngTemplateOutlet to render the control.

    File: pass-container-instance.ts

    Key Components:

    @Component({
      selector: 'component-a',
      template: `
        <div>component-a</div>
        <div>
          <ng-container *ngTemplateOutlet="template; context: { control: control }"></ng-container>
        </div>
      `,
    })
    class ComponentA {
      @Input() control: FormControl | null = null;
      @ContentChild(TemplateRef) template!: TemplateRef<any>;
    }
    
    @Component({
      selector: 'component-b',
      template: `
        <div>component-b</div>
        <div>
          <component-a [control]=control>
            <ng-template let-control="control">
              <input [formControl]="control" ngDefaultControl>
            </ng-template>
          </component-a>
        </div>
      `,
    })
    class ComponentB {
      @Input() control: FormControl | null = null;
    }
    
    @Component({
      selector: 'app-frame',
      template: `
        <div>
          <h1>FrameComponent</h1>
          <form [formGroup]="form">
            <input formControlName="name" placeholder="Name">
            <component-b [control]="surnameControl"/>
          </form>
          {{ form.value | json }}
        </div>
      `,
    })
    class FrameComponent {
      surnameControl = new FormControl('Surname');
      form = new FormGroup({
        name: new FormControl('Name'),
        surname: this.surnameControl,
      });
    }
    

    2. Using pass-control-instance.ts

    In this approach, the entire form group is passed to a child component, and the child component uses ngTemplateOutlet to render the control within the form group.

    File: pass-control-instance.ts

    Key Components:

    @Component({
      selector: 'component-a',
      template: `
        <div>component-a</div>
        <div>
          <ng-container *ngTemplateOutlet="template; context: { control: control }"></ng-container>
        </div>
      `,
    })
    class ComponentA {
      @Input() control: FormControl | null = null;
      @ContentChild(TemplateRef) template!: TemplateRef<any>;
    }
    
    @Component({
      selector: 'component-b',
      template: `
        <div>component-b</div>
        <div>
          <component-a [form]=form>
            <ng-template let-control="control">
              <div [formGroup]="form">
                <input formControlName="surname" ngDefaultControl>
              </div>
            </ng-template>
          </component-a>
        </div>
      `,
    })
    class ComponentB {
      @Input() form: FormGroup | null = null;
    }
    
    @Component({
      selector: 'app-frame',
      template: `
        <div>
          <h1>FrameComponent</h1>
          <form [formGroup]="form">
            <input formControlName="name" placeholder="Name">
            <component-b [form]="form"/>
          </form>
          {{ form.value | json }}
        </div>
      `,
    })
    class FrameComponent {
      form = new FormGroup({
        name: new FormControl('Name'),
        surname: new FormControl('Surname'),
      });
    }
    

    3. Using cc-injecting-directive.ts

    In this approach, a custom directive is used to inject the parent control container into the child component.

    File: cc-injecting-directive.ts

    Key Components:

    @Directive({
      selector: '[parentControlContainer]',
      providers: [
        {
          provide: ControlContainer,
          useFactory: () => inject(ControlContainer, { skipSelf: true }),
        }
      ]
    })
    export class ParentControlContainer {
      constructor(
        @Optional()
        @SkipSelf()
        parent: ControlContainer,
        private injector: Injector
      ) {
        this.logParentChain();
      }
    
      private logParentChain() {
        let currentInjector: Injector | null = this.injector;
        while (currentInjector) {
          const parentControlContainer = currentInjector.get(ControlContainer, null);
          console.log('Parent ControlContainer:', parentControlContainer);
          currentInjector = (currentInjector as unknown as {parent: Injector}).parent;
        }
      }
    }
    
    @Component({
      selector: 'component-b',
      template: `
        <div>component-b</div>
        <div>
          <component-a>
            <ng-template>
              <ng-container parentControlContainer>
                <input formControlName="surname" ngDefaultControl>
              </ng-container>
            </ng-template>
          </component-a>
        </div>
      `,
    })
    class ComponentB {
      @Input() name!: string;
    }
    
    @Component({
      selector: 'component-a',
      template: `
        <div>component-a</div>
        <div>
          <ng-container *ngTemplateOutlet="template"></ng-container>
        </div>
      `,
    })
    class ComponentA {
      @Input() name!: string;
      @ContentChild(TemplateRef) template!: TemplateRef<any>;
    }
    
    @Component({
      selector: 'app-frame',
      template: `
        <div>
          <h1>FrameComponent</h1>
          <form [formGroup]="form">
            <input formControlName="name" placeholder="Name">
            <component-b />
          </form>
          {{ form.value | json }}
        </div>
      `,
    })
    class FrameComponent {
      form = new FormGroup({
        name: new FormControl('Name'),
        surname: new FormControl('Surname'),
      });
    }
    

    4. Using main-dirrective-override.ts

    In this approach, a custom directive is used to override the default FormControlName directive.

    File: main-dirrective-override.ts

    Key Components:

    const controlNameBinding: Provider = {
      provide: NgControl,
      useExisting: forwardRef(() => OutControlForm),
    };
    
    @Directive({
      selector: '[outFormControlName]',
      providers: [controlNameBinding],
    })
    export class OutControlForm extends FormControlName {
      @Input('outFormControlName') override name: string | number | null = null;
    
      constructor(
        @Optional()
        @SkipSelf()
        parent: ControlContainer,
        @Optional() @Self() @Inject(NG_VALIDATORS) validators: (Validator | ValidatorFn)[],
        @Optional()
        @Self()
        @Inject(NG_ASYNC_VALIDATORS)
        asyncValidators: (AsyncValidator | AsyncValidatorFn)[],
        @Optional() @Self() @Inject(NG_VALUE_ACCESSOR) valueAccessors: ControlValueAccessor[],
        private injector: Injector
      ) {
        super(parent, validators, asyncValidators, valueAccessors, null);
        this.logParentChain();
      }
    
      private logParentChain() {
        let currentInjector: Injector | null = this.injector;
        while (currentInjector) {
          const parentControlContainer = currentInjector.get(ControlContainer, null);
          console.log('Parent ControlContainer:', parentControlContainer);
          currentInjector = (currentInjector as unknown as {parent: Injector}).parent;
        }
      }
    }
    
    @Component({
      selector: 'component-a',
      template: `
        <div>component-a</div>
        <div>
          <ng-container *ngTemplateOutlet="template"></ng-container>
        </div>
      `,
    })
    class ComponentA {
      @Input() name!: string;
      @ContentChild(TemplateRef) template!: TemplateRef<any>;
    }
    
    @Component({
      selector: 'component-b',
      template: `
        <div>component-b</div>
        <div>
          <component-a>
            <ng-template>
              <input outFormControlName="surname" ngDefaultControl>
            </ng-template>
          </component-a>
        </div>
      `,
    })
    class ComponentB {
      @Input() name!: string;
    }
    
    @Component({
      selector: 'app-frame',
      template: `
        <div>
          <h1>FrameComponent</h1>
          <form [formGroup]="form">
            <input formControlName="name" placeholder="Name">
            <component-b />
          </form>
          {{ form.value | json }}
        </div>
      `,
    })
    class FrameComponent {
      form = new FormGroup({
        name: new FormControl('Name'),
        surname: new FormControl('Surname'),
      });
    }
    

    Each of these approaches demonstrates a different way to attach a control to a control group in Angular. Depending on your specific requirements, you can choose the approach that best fits your needs.


    This summary provides an overview of the four different approaches used in the project. You can refer to the respective files for more details on each implementation.

    A.I. helped me to assemble it. I am not so smart.