2

I know this question has been asked before but I have tried several answers and nothing is working. I am struggling with the infamous ExpressionChangedAfterItHasBeenCheckedError. The code is working however the error message appears in the console.

Error:

ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'disabled: false'. Current value: 'disabled: true'.

After removing the [disabled]="itemForm.invalid" condition, this new error appears.

ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'ng-valid: true'. Current value: 'ng-valid: false'.

Description:

I have a Material Table that displays multiple rows of data. A button "Edit" is shown next to each row. The row is passed in to the onRowEdit function as an argument on the click event.

(click)="onEditItem(row)"

The onEditItem function opens a Mat Dialog component and passes in the row from the template as the Dialog Data. The Dialog data is then used to populate a FormGroup to display the values inside of a form.

I was able to determine that the error is caused by passing in the row from the template directly. When a row is not passed in, or the values are hard-coded, it works fine without the error.

For example, the "Add" button onAddRow opens up the exact same dialog and works without the error. The Add button is shown at the table header and does not pass in a row from the template, just initializes an empty object and passes it into the dialog data. Also, if the values are simply hard-coded in the onEditRow function (without passing the row from the template) the dialog loads and populates the form with no errors.

Attempts to fix:

I attempted to use many techniques including forcing change detection, moving the logic to AfterView init, many other answers on this site here and here as well as various suggestions provided in blog posts here and here and more. Unfortunately I am still grappling with this issue and am puzzled as to why it works when data is hard coded, but not when it is passed in from the template.

Code

Table Component

TS

  // ERROR WHEN A ROW IS PASSED IN FROM THE TEMPLATE DIRECTLY
  public onEditItem(item): void {
    const dialogData = {
      editMode: true,
      selectedItem: item
      // WORKS WITHOUT THE ERROR IF ITEM VALUES ARE HARD-CODED INTO THE DIALOG DATA VARIABLE
      // selectedItem: { Title: 'Example', Description: 'Example description' }
    };

    this.openItemDetailDialog(dialogData);
  }

  // WORKS
  public onAddItem(): void {
    const dialogData = {
      editMode: false,
      selectedItem: {}
    }
    this.openItemDetailDialog(dialogData);
  }

  private openItemDetailDialog(dialogData): void {
    const dialogRef = this.dialog.open(ItemDetailComponent, {
      height: '90%',
      width: '90%',
      data: dialogData,
      disableClose: true,
    });
  }

HTML

<table mat-table [dataSource]="itemsDataSource">
  <ng-container [matColumnDef]="column.name" *ngFor="let column of initCols;">
    <th mat-header-cell *matHeaderCellDef>
      {{ column.display }}
    </th>
    <td mat-cell *matCellDef="let element">
      <ng-container>
        {{ element[column.name] }}
      </ng-container>
    </td>
  </ng-container>
  <!-- Table actions -->
  <ng-container matColumnDef="actions">
    <th mat-header-cell *matHeaderCellDef>
      <div mat-header>
        <button type="button" mat-button
          (click)="onAddItem()">
          <mat-icon>add_circle</mat-icon>
        </button>
      </div>
    </th>
    <td mat-cell class="xs" *matCellDef="let row">
      <ng-container>
        <button type="button" mat-button
          (click)="onEditItem(row)">
          <mat-icon>edit</mat-icon>
        </button>
      </ng-container>
    </td>
  </ng-container>
  <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
  <tr mat-row *matRowDef="let row; columns: displayedColumns;" [ngClass]="uiService.getClassUnsaved(deletedItems, row)"></tr>
</table>

Dialog Component

TS

ngOnInit() {

  // Get currently Selected Item to populate Item Form values
  this.item = this.dialogData.selectedItem;

  // Initialize Item values
  const itemValues = this.itemService.initItemValues(this.item);

  // Initialize Item Form
  this.itemService.initItemForm(itemValues);

  // Get current value of Item Form
  this.itemFormSub = this.itemService.itemForm$
    .subscribe(itemForm => {
      // Set local class member variable to populate the FormGroup in the template
      this.itemForm = itemForm;
      this.isLoadingResults = false;
    });

}

HTML

<h1 mat-dialog-title>Item Detail</h1>
<!-- Form -->
<form [formGroup]="itemForm" (ngSubmit)="onSubmit()">
  <div mat-dialog-content>
  <mat-form-field appearance="outline" floatLabel="always">
    <mat-label>Call Number</mat-label>
    <input matInput formControlName="Title" required="true">
  </mat-form-field>
  <!-- Actions --->
  <div mat-dialog-actions>
    <button type="submit" mat-raised-button color="primary"
      [disabled]="itemForm.invalid">
      Save Item
    </button>
  </div>
</form>

Service

// Create a BehaviorSubject to track and share updates to Record Form value
private itemForm: FormGroup;
itemFormSubject: BehaviorSubject<FormGroup> = new BehaviorSubject(this.itemForm);
itemForm$ = this.itemFormSubject.asObservable();

// Initialize Item values
public initItemValues(item: Item | any): Item {

  const itemValues: Item = {
    Title: item && item.Title ? item.Title : null,
    Description: item && item.Description ? item.Description : null,
  };

  return itemValues;
}

// Initialize a new Item Form and pre-populate values
public initItemForm(item: Item): void {

  // Initialize a new Item Form Group
  const itemForm = this.fb.group({
    Title: [item.Title, Validators.compose([
      Validators.minLength(3),
      Validators.maxLength(10),
      Validators.pattern(/^[A-Za-z0-9]*$/)
    ])],
    Description: [item.Description, Validators.compose([
      Validators.minLength(3),
      Validators.maxLength(100),
      Validators.pattern(/^[A-Za-z0-9]*$/)
    ])]
  });

  // Update current value of Item Form Subject to share with subscribers
  this.itemFormSubject.next(itemForm);

}

UPDATE 1:

As was suggested by a helpful person, I attempted to clone the item parameter before passing it into the dialog. Unfortunately, this did not work.

Attempt clone 1:

  public onEditItem(item): void {
    const selectedItem = this.uiService.deepCopy(item);
    const dialogData = {
      editMode: true,
      selectedItem: selectedItem
    };

    this.openItemDetailDialog(dialogData);
  }

Attempt clone 2:

  public onEditItem(item): void {
    const selectedItem = this.uiService.deepCopy(item);
    const dialogData = {
      editMode: true,
      selectedItem: {
        Title: item.Title,
        Description: item.Description
      }
    };

    this.openItemDetailDialog(dialogData);
  }

UPDATE 2:

I think this might be an issue caused by when one of the Item properties is undefined. I tested it out by hard-coding the values and replacing them one by one with the Item properties. It works up until I got to a property that is undefined. For example if Description is undefined it throws the error. To resolve this issue, I made sure that all of the item properties are set before passing it into the dialog and populating the form controls.

      selectedItem: {
        Title: item.Title,
        Description: item.Description // Might be caused by this property being undefined
      }

UPDATE 3:

The issue is definitely caused by form controls that are required but are not being populated with an initial value. In update 2 above, Description is required but item.Description was undefined. So it works if I set a value for Description manually. However, it does not work if any required properties are initialized in the form with null values. I thought I was able to solve this by setting values but it turns out this is still a problem because in some cases the item does not have properties that are required when the form is loaded.

Also, cloning the item property does not seem to solve the issue.

UPDATE 4:

Another possible cause of the issue is in the template I set the required attribute in the "template-driven" way required="true" on the form control but forgot to also set it to required in the "reactive" way Validators.required. I set the required attribute in the template because I was running into this issue regarding the missing asterisk. I added the required validator in the TS component and the error doesn't seem to be appearing anymore. However, I am not completely convinced it is solved because I wasn't able to reproduce it again after setting it back to the way it was before. I suspect that this is some sort of intermittent issue that might appear at a later time.

pengz
  • 2,279
  • 3
  • 48
  • 91

1 Answers1

1

Proposed Solution

My guess is that you can solve this by cloning the item parameter after it has been passed in to onEditItem.

Theory on Root Issue

I think that the issue is caused by the fact that the item parameter accepted in onEditItem is a reference which is used in the data sources used by the mat table. Where I think it throws the error because you're changing the object while it's being used by the table.

Bit advanced case so easiest to test to make sure I'd say.

Koslun
  • 2,164
  • 1
  • 23
  • 17
  • 1
    Thanks! I just tried deep copying the item parameter before passing into the dialog but unfortunately it didn't work. ` const selectedItem = this.uiService.deepCopy(item);` – pengz Jul 19 '19 at 18:51
  • 1
    Ok, too bad! Will have a bit of a deeper look then. – Koslun Jul 19 '19 at 19:00
  • 1
    Thanks I appreciate it. That's a good thought and I was thinking the same thing, although I'm not sure if that could be it because the form is updating with no issues when the values are simply hard-coded. I will keep updating when I have more information. Thanks again. – pengz Jul 19 '19 at 19:04