Search code examples
angularinheritanceformgroupsng-content

Angular - FormGroup and ng-content


I've got some data very similar defined by an ID and a description. Sometimes, there are additional attributes (a price, for example).

My goal is to create a simple form to get the ID and the description and if necessary, the others inputs.

I would like to create a form component (SimpleFormComponent) where:

  • The HTML file contains the form tag and inputs for the ID and the description.
  • The TS file includes the business logic (API calls...).

If an entity has more properties, we can extend SimpleFormComponent to add needed inputs and change the business logic.

What I imagine

In a first time, I've created a SimpleFormComponent which can be use for basic data.

Usage of SimpleFormComponent

Its HTML template contains the form and a <ng-content></ng-content> tag for needed inputs :

<form [formGroup]="form" (submit)="submit()">
  <h3>{{ entityName }}</h3>

  <input type="text" name="code" id="code" placeholder="Code" formControlName="code">
  <input type="text" name="description" id="description" 
    placeholder="Description" formControlName="description">

  <ng-content></ng-content>

  <button type="reset">Cancel</button>
  <button type="submit" [disabled]="form.invalid">Save</button>
</form>

Its .ts file contains the business logic and necessary attributes like API endpoint or entity name :

@Component({
  ...
})
export class SimpleFormComponent implements OnInit {

  protected ENTITY_NAME: string;
  protected API_ENDPOINT: string;

  public form: FormGroup = this.fb.group({
    code: ['', [Validators.required]],
    description: [''],
  });

  constructor(
    private route: ActivatedRoute, 
    private fb: FormBuilder, 
    private api: ApiService
  ) {}

  ngOnInit() {
    // Get ENTITY_NAME and API_ENDPOINT from route parameters
  }

  submit() {
    const toAdd = this.doBusinessLogic();
    this.api.add(toAdd).subscribe({ ... });
  }

  private doBusinessLogic(): any {
    let result;
    // Do some stuff with result and this.form
    return result;
  }
}

This component works well for a basic entity (only an ID and a description).

Then, I've tried to create a TestFormComponent inherited from SimpleFormComponent. Its HTML template looks like this:

<app-simple-technical-data>
  <input type="text" name="test" id="test" formControlName="test">
</app-simple-technical-data>

And its .ts file like this:

@Component({
  selector: 'app-test',
  templateUrl: './test.component.html',
  styleUrls: ['./test.component.scss'],
})
export class TestComponent
  extends SimpleFormComponent
  implements OnInit
{
  test = new FormControl('');

  constructor(
    route: ActivatedRoute,
    router: Router,
    formBuilder: FormBuilder,
    ability: AppAbility,
    technicalDataService: TechnicalDataService
  ) {
    super(route, router, formBuilder, ability, technicalDataService);
  }

  override ngOnInit(): void {
    super.ngOnInit();
    this.form.addControl('test', this.test);
  }

  override submit() {
    let result: any;
    // Do another stuff with result
    this.api.add(result).subscribe({ ... });
  }
}

However, I get the following error :

ERROR Error: NG01050: formControlName must be used with a parent formGroup directive.  
You'll want to add a formGroup directive and pass it an existing FormGroup instance 
(you can create one in your class).

My "test" input is displayed but I can't get its data. In addition, it calls SimpleFormComponent.submit() instead of TestComponent.submit().

I don't really know why it doesn't "see" the formGroup in the parent component and how fix this error.

If my structure is incorrect, how can I change it ?

Thanks in advance for your help and advices.


Solution

  • Your approach is difficult to implement. You are using content projection (ng-conent) for the additional form fields. But the html of ng-content is not just simply copied into the parent component's html. It will not work that way. Also you mixed two concepts: You connected TestComponent and SimpleFormComponent by two ways: You use SimpleFormComponent as a ChildComponent AND you inherit TestComponent form SimpleFormComponent. This causes problems like ngOnInit of SimpleFormComponent will be called twice (1x by directly calling super.ngOnInit and 1x by angular because of the usage of the component in the template).

    My proposal for a solution is:

    1. Create a simple presentational component only for the basic fields (id and description) - let'a call it BasicDataComponent. It only contains the html for the two fields. FormGroup can be given as an input binding.
    2. Create separate components for the additional fields which use BasicDataComponent as child component. So e.g. your TestComponent can use app-basic-data-componet inside of its template. TestComponent can create the form (e.g. using FormBuilder) and insert it into BasicDataComponent using input binding (e.g. [form]="form").
    3. Create a service for the business logic, which is reused.

    This would be a clean design IMHO.

    Hints:

    • You can define interfaces for the types of your data that use inheritance. You can use the parent interface (e.g. BasicData) inside of BasicDataComponent and you can create specialized child interfaces that inherit from BasicData (e.g. TestData extends BasicData). By that you can create a FormGroup in you parent component and use it as FormGroup in your child component.
    • If you still have common logic inside of your parent components which doesn't 'fit' into your service, use can create an abstract class for your components and place the logic there.