Search code examples
angularangular-reactive-formsangular-controlvalueaccessor

Angular Reactive Forms for property grid


Using Angular 12, I want to validate and detect changes in a list.

I have a property grid (a table of key/value pairs, listing properties with editable values, but each value can be of a different type, string/boolean/int/etc.).

I want to add validation and changes detection for that property grid.

Validation should happen for each item and the changes detection only needs to occur for the list as a whole (not caring which row/item was changed).

I've built something like this:

export class InnerSetting {
  key!: string;
  displayName!: string;
  originalValue?: string;
  value?: string;
  type!: PropertyTypeEnum; //string | int | boolean | ...
  validation?: string;
  minValue?: number;
  maxValue?: number;
  isNullable!: boolean
}
constructor(private formBuilder: FormBuilder) {
    this.form = this.formBuilder.group({
      properties: new FormArray([])
    });
  }

  ngOnInit(): void {
    this.settings.forEach(item => this.formArray.push(this.formBuilder.group(
      {
        key: [item.key],
        type: [item.type],
        name: [item.displayName],
        value: [ item.value, [Validators.required, Validators.minLength(2)] ], //Depends on type and validation stuff, will be added later.
        originalValue: [ item.originalValue ]
      }
    )));

    //Not sure which of these will work, so added both for now.
    this.form.valueChanges.pipe(debounceTime(500)).subscribe(changes => {
      this.areChangesDetected = changes != null;
    });

    this.formArray.valueChanges.pipe(debounceTime(500)).subscribe(changes => {
      this.areChangesDetected = changes != null;
    });
  }

  get formArray() {
    return this.form.controls.properties as FormArray;
  }

Before using a Form, I was just using a InnerSetting list, so bear in mind that I just started replacing the list with the form.

The setting property was an InnerSetting object.

<form [formGroup]="form" class="group-wrapper">
  <div class="d-flex item-row" *ngFor="let setting of formArray.controls; let i = index">
    <div class="item flex-fill d-flex" [ngSwitch]="setting.type" [formGroupName]="i">
      <span>{{setting.name}}</span>

      <select *ngSwitchCase="'boolean'" class="flex-grow-1" name="role" id="role-select" [(ngModel)]="setting.value">
        <option [value]="'0'">False</option>
        <option [value]="'1'">True</option>
      </select>

      <div contenteditable *ngSwitchDefault class="flex-grow-1" type="text" [id]="setting.key" [innerText]="setting.value"></div>
    </div>

    <button class="remove-relation" (click)="reset(setting)">
      <fa-icon [icon]="faUndo"></fa-icon>
    </button>
  </div>
</form>

Issues

Since I need to display different elements based on the setting type (boolean, string, number, etc). How can I access that information from the formArray.controls?

Also, how can I bind to non-standard input controls such as my div with a contenteditable?


Edit

I noticed that the formArray.controls is an array of FormGroup and that I can access the values in this.formArray.controls[0].controls['name'].value.

Now the issue is setting that control to the fields (select or div or input) based on the type.


Solution

  • Your template will look like this

    <form [formGroup]="form" class="group-wrapper">
      <div
        formArrayName="properties"
        class="d-flex item-row"
        *ngFor="let setting of settingFG; let i = index"
      >
        <div
          class="item flex-fill d-flex"
          [ngSwitch]="setting.get('type').value"
          [formGroupName]="i"
        >
          <!-- <span>{{ setting.get('name').value }}</span> -->
    
          <select
            *ngSwitchCase="'boolean'"
            class="flex-grow-1"
            name="role"
            id="role-select"
            formControlName="value"
          >
            ...
          </select>
          // custom div form control
          <div-control *ngSwitchDefault formControlName="value"></div-control>
    
        </div>
    
        <button class="remove-relation" (click)="reset(setting)">X</button>
      </div>
    </form>
    

    Helper method to get array of FormGroup

     get settingFG(): FormGroup[] {
        return this.formArray.controls as FormGroup[];
      }
    

    To add FormControl to div we need to implement ControlValueAccessor

    export const DIV_VALUE_ACCESSOR: any = {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => DivController),
      multi: true,
    };
    
    @Component({
      selector: 'div-control',
      providers: [DIV_VALUE_ACCESSOR],
      template: `<div contenteditable #div (input)="changeText(div.innerText)" [textContent]="value"><div>`,
    })
    export class DivController implements ControlValueAccessor {
      value = '';
      disabled = false;
      private onTouched!: Function;
      private onChanged!: Function;
    
      changeText(text: string) {
        this.onTouched(); // <-- mark as touched
        this.value = text;
        this.onChanged(text); // <-- call function to let know of a change
      }
      writeValue(value: string): void {
        this.value = value ?? '';
      }
      registerOnChange(fn: any): void {
        this.onChanged = fn; // <-- save the function
      }
      registerOnTouched(fn: any): void {
        this.onTouched = fn; // <-- save the function
      }
      setDisabledState(isDisabled: boolean) {
        this.disabled = isDisabled;
      }
    }
    

    *Don't forget to add in declaration array in the module.

    Full Demo