Search code examples
angulartypescriptnestedcomponentsparent-child

Angular does not detect changes through nested components


I have found this problem that is like mine. Yet, my implementation does not work despite following the steps.
I have the following component structure:

  • Dashboard
    • ActionButton
    • Milestone
      • Table
    • SupplierSearch
      • Table

I have tried to pass array selectedRows from Table to Dashboard, and then to ActionButton using the CustomEvent and elRef.nativeElement.dispatchEvent. When I tried to console.log to see if it is passed to any parent components(Dashboard or Milestone) from Dashboard/Milestone/Table, the array simply does not get passed.

Please note that my code is super dirty right now because I have been trying to resolve this issue for almost a day and tried many ways to resolve it. Please focus on the my way to implement this mentioned solution (CustomEvent elRef.nativeElement.dispatchEvent)

I really appreciate the Stackoverflow community for the shared knowledge, thus, please don't downgrade this post if my English is bad or something is inherently wrong with my problem.

Table

import {
  Component,
  ElementRef,
  EventEmitter,
  Input,
  Output,
  TemplateRef,
} from '@angular/core';
import { TableColumnHeader } from './models/table-column-header';

@Component({
  selector: 'app-table',
  templateUrl: './table.component.html',
  styleUrls: ['./table.component.scss'],
})
export class TableComponent {
  @Input() rowData;
  @Input() headers: TableColumnHeader[] = [];
  @Input() columnTemplate: TemplateRef<any>;
  @Input() loading: boolean = false;

  @Output() selectedRowsEvent = new EventEmitter<any[]>();

  selectedRows = [];
  constructor(private elRef: ElementRef) {}

  onRowSelect(event) {
    this.selectedRows.push(event.data);
    this.selectedRowsEvent.emit(this.selectedRows);

    const evt = new CustomEvent('myCustomEvent', {
      bubbles: true,
      detail: event,
    });
    this.elRef.nativeElement.dispatchEvent(evt);

    console.log(this.selectedRows);
    console.log(event);
    console.log('from table onRowSelected ');
  }

  onRowUnselect(event) {
    this.selectedRows = this.selectedRows.filter(
      (x) => x.nvtAreaName !== event.data.nvtAreaName
    );
    this.selectedRowsEvent.emit(this.selectedRows);
    console.log(this.selectedRows);
    console.log('from table onRowUnselected ');
  }

  // onPage(event) {
  //   this.selectedRows = [];
  //   this.selectedRowsEvent.emit(this.selectedRows);
  // }
}

Table Template

<ng-template #columnTemplate let-rowObject="rowObject" let-id="id">
  <ng-container [ngSwitch]="id">
    <span *ngSwitchDefault>{{ rowObject[id] | translate }}</span>
  </ng-container>
</ng-template>
<ng-template #dateColumnTemplate let-rowObject="rowObject" let-id="id">
  <ng-container [ngSwitch]="id">
    <span *ngSwitchDefault>{{ rowObject[id] | localizedDate }}</span>
  </ng-container>
</ng-template>

<p-table
  (onRowSelect)="onRowSelect($event)"
  (onRowUnselect)="onRowUnselect($event)"
  [paginator]="true"
  [rows]="10"
  [showCurrentPageReport]="true"
  currentPageReportTemplate="{{ 'PAGINATION' | translate }}"
  [rowsPerPageOptions]="[10]"
  [value]="rowData"
  [loading]="loading"
  [tableStyle]="{ 'min-width': '79rem' }"
>
  <ng-template pTemplate="header">
    <tr>
      <th style="width: 4rem">
        <p-tableHeaderCheckbox></p-tableHeaderCheckbox>
      </th>
      <ng-container *ngFor="let header of headers">
        <th
          *ngIf="header.sortable; else simpleHeader"
          [pSortableColumn]="header.id"
        >
          {{ header.value | translate }}
          <p-sortIcon [field]="header.id"></p-sortIcon>
        </th>
        <ng-template #simpleHeader>
          <th>
            {{ header.value | translate }}
          </th>
        </ng-template>
      </ng-container>
    </tr>
  </ng-template>
  <ng-template pTemplate="body" let-rowObject>
    <tr>
      <td>
        <p-tableCheckbox [value]="rowObject"></p-tableCheckbox>
      </td>
      <td *ngFor="let header of headers">
        <ng-container
          [ngTemplateOutlet]="
            header?.date ? dateColumnTemplate : columnTemplate
          "
          [ngTemplateOutletContext]="{ rowObject: rowObject, id: header.id }"
        ></ng-container>
      </td>
    </tr>
  </ng-template>
</p-table>

Milestone

import {
  AfterViewInit,
  Component,
  Inject,
  Input,
  OnChanges,
  OnInit,
  SimpleChanges,
  ViewChild,
} from '@angular/core';
import { TableColumnHeader } from '../../table/models/table-column-header';
import { NvtAreaDataSource } from '../../../services/nvt-area-data-source.service';
import { AreaProgramDataSource } from '../../../services/area-program-data-source.service';
import { MilestoneTableColumn } from '../../../models/business/milestone-table-column';
import { TableComponent } from '../../table/table.component';

@Component({
  selector: 'app-milestone-search',
  templateUrl: './milestone-search.component.html',
  styleUrls: ['./milestone-search.component.scss'],
})
export class MilestoneSearchComponent
  implements OnInit, OnChanges, AfterViewInit
{
  @Input() selectedGigaArea: string;
  milestoneData = [];
  loading: false;
  private tableComponent!: TableComponent;
  selectedRows = [];

  @Input() pSelectableRows = [];
  @ViewChild(TableComponent)
  columnHeaders: TableColumnHeader[] = [
    { value: 'ONKZ', id: 'onkz', sortable: true },
    { value: 'NVT', id: 'nvtAreaName', sortable: true },
    { value: 'STATUS', id: 'status', sortable: true },
    { value: 'ARVM_START', id: 'arvMStart', date: true },
    { value: 'EXP.ROUGH_START', id: 'expRoughStart', date: true },
    { value: 'EXP.ROUGH_END', id: 'expRoughEnd', date: true },
    { value: 'EXP.FINE_START', id: 'expFineStart', date: true },
    { value: 'EXP.FINE_END', id: 'expFineEnd', date: true },
    { value: 'RM_START', id: 'rmStart', date: true },
    { value: 'AFTER_INST_START', id: 'afterInstStart', date: true },
    { value: 'AFTER_INST_END', id: 'afterInstEnd', date: true },
  ];

  constructor(
    @Inject(NvtAreaDataSource) private nvtAreaDataSource,
    @Inject(AreaProgramDataSource) private areaProgramDataSource
  ) {}

  ngOnInit(): void {
    this.nvtAreaDataSource.connect().subscribe((nvtAreas) => {
      this.milestoneData = [...nvtAreas];
    });
    this.areaProgramDataSource.connect().subscribe((areaPrograms) => {
      this.milestoneData = this.mergeMilestonesData(
        this.milestoneData,
        areaPrograms
      );
    });
  }

  ngOnChanges(changes: SimpleChanges) {
    if (changes.date) {
      // this.pSelectableRows = changes.date.currentValue;
      this.selectedRows = changes.data.currentValue;
    }
    console.log('from milestone onChanges  ' + this.selectedRows.length);
  }
  ngAfterViewInit(): void {
    this.selectedRows = this.tableComponent.selectedRows;
    console.log('from ngAfterViewInit ');
  }

  onNotify(rowsEmitted: any[]): void {
    console.log('from milestone onNotify ');
    this.selectedRows = rowsEmitted;
  }

  mergeMilestonesData(nvtAreas, areaPrograms) {
    return nvtAreas.map((nvtArea) => {
      const areaProgram = areaPrograms.find(
        (x) => x.nvtAreaId === nvtArea.nvtAreaId
      );
      if (!areaProgram) return nvtArea;
      const { status, milestones } = areaProgram;
      let milestonesColumns = {};
      milestones.map((milestone) => {
        const milestonesColumn = Object.entries(
          new MilestoneTableColumn()
        ).reduce(
          (acc, [key, value]) =>
            value === milestone.milestoneType
              ? {
                  ...acc,
                  [key]: milestone.milestoneDate,
                }
              : acc,
          {}
        );
        milestonesColumns = { ...milestonesColumns, ...milestonesColumn };
      });
      return {
        ...nvtArea,
        ...milestonesColumns,
        status,
      };
    });
  }
}

Milestone template

<app-table
  (selectedRowsEvent)="onNotify($event)"
  [pSelectableRows]="forms.get('selectedRows')"
  *ngIf="milestoneData?.length"
  [rowData]="milestoneData"
  [headers]="columnHeaders"
  [loading]="loading"
>
</app-table>

Dashboard

import {
  AfterViewInit,
  Component,
  OnChanges,
  SimpleChanges,
  ViewChild,
} from '@angular/core';
import { TableComponent } from '../../table/table.component';
import { AreabarComponent } from '../areabar/areabar.component';

@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html',
  styleUrls: ['./dashboard.component.scss'],
})
export class DashboardComponent implements AfterViewInit {
  selectedRowsEvent($event: any) {
    throw new Error('Method not implemented.');
  }
  @ViewChild(TableComponent)
  selectedRows = [];
  private tableComponent!: TableComponent;

  public selectedGigaArea: string;
  public totalElements: number;
  @ViewChild(AreabarComponent) areabarComponent: AreabarComponent;
  constructor() {}
  ngAfterViewInit(): void {
    this.selectedRows = this.tableComponent.selectedRows;
    console.log('from ngAfterViewInit ');
  }

  // ngOnChanges(changes: SimpleChanges): void {
  //   console.log('from Dashboard ngOnChanges ');
  //   throw new Error('Method not implemented.');
  // }

  onNotify(rowsEmitted: any[]): void {
    console.log('from Dashboard onNotify ');
    this.selectedRows = rowsEmitted;
  }
}

Dashboard template

<div class="dashboard-wrapper">
  <div id="giga-areas" class="giga-areas">
    <app-areabar
      (selectedGigaAreaEvent)="selectedGigaArea = $event"
      (totalElementsChanged)="totalElements = $event"
    ></app-areabar>
  </div>
  <div class="search-wrapper">
    <h1 class="page-header">{{ "SEARCH.ROLLOUT_PROJECT" | translate }}</h1>
    <nav class="nav-bar">
      <mat-button-toggle-group
        #toggleGroup="matButtonToggleGroup"
        class="toggle-btn"
      >
        <mat-button-toggle value="supplier" checked>{{
          "SUPPLIERS" | translate
        }}</mat-button-toggle>
        <mat-button-toggle value="milestone">{{
          "MILESTONES" | translate
        }}</mat-button-toggle>
      </mat-button-toggle-group>
      <app-action-button (myCustomEvent)="onNotify($event)"></app-action-button>
    </nav>
    <div [className]="toggleGroup.value === 'supplier' || 'hide'">
      <app-supplier-search
        class="nvt-search"
        [selectedGigaArea]="selectedGigaArea"
      ></app-supplier-search>
    </div>
    <div [className]="toggleGroup.value === 'milestone' || 'hide'">
      <app-milestone-search
        (selectedRowsEvent)="onNotify($event)"
        class="nvt-search"
        [selectedGigaArea]="selectedGigaArea"
      ></app-milestone-search>
    </div>
    <div *ngIf="!selectedGigaArea" class="infoText">
      {{ "VIEW_EDIT_NVT_AREA" | translate }}
    </div>
    <div *ngIf="!selectedGigaArea && totalElements > 20" class="infoText">
      {{ "GIGAAREA_OVERLOAD_MESSAGE" | translate }}
    </div>
  </div>
  <app-search class="nvt-search" [selectedGigaArea]="selectedGigaArea">
  </app-search>
</div>

ActionButton


import {
  Component,
  Inject,
  Input,
  OnChanges,
  OnInit,
  SimpleChanges,
} from '@angular/core';
import { AuthenticationProvider } from 'src/app/services/auth/auth-service.injection-token';
import { AuthService } from 'src/app/services/auth/auth.service';
import { MatDialog } from '@angular/material/dialog';
import { PopupsComponent } from '../shared/popups/popups.component';
import { Router } from '@angular/router';
const backUrl = '/home';
const createrolloutprojects = '/createrolloutprojects';
const changeMilestonesUrl = '/changemilestones';
const changeSupplierUrl = '/changesupplier';
const viewDetailsUrl = '/viewdetails';
@Component({
  selector: 'app-action-button',
  templateUrl: './action-button.component.html',
  styleUrls: ['./action-button.component.scss'],
})
export class ActionButtonComponent implements OnInit, OnChanges {
  selectedRows = [];

  constructor(
    public dialog: MatDialog,
    @Inject(AuthenticationProvider)
    private permissionService: AuthService,
    private router: Router
  ) {}

  ngOnInit(): void {
    this.router.navigate([backUrl]);
    console.log('from action button component ');
    console.log(this.selectedRows);
  }

  ngOnChanges(changes: SimpleChanges): void {
    console.log('ngonchanges trigged ');
    console.log(this.selectedRows);
  }

  getPermission(permissionKey: string): boolean {
    return !this.permissionService.hasPermission(permissionKey);
  }

  onNotify(rowsEmitted: any[]): void {
    console.log('from action button onNotify');
    this.selectedRows = rowsEmitted;
  }

  openPopupDialog(): void {
    console.log('from openPopupDialog');
    const dialogRef = this.dialog.open(PopupsComponent, {
      width: '900px',
      height: '404px',
      disableClose: true,
      autoFocus: false,
      data: {
        title: 'CHANGE_AREA_PROGRAM_STATE.TITLE',
        plainTextDescription:
          'CHANGE_AREA_PROGRAM_STATE.PLAIN_TEXT_DESCRIPTION',
        bulletPointDescription:
          'CHANGE_AREA_PROGRAM_STATE.BULLET_POINT_DESCRIPTION',
        linkText: '',
        externalLink: 'https://...', //<- url belonging to lintText
        info: 'CHANGE_AREA_PROGRAM_STATE.INFO',
      },
    });

    dialogRef.afterClosed().subscribe((result) => {
      console.log(result);
    });
  }
}

ActionButton template

<div>
  <button mat-button [matMenuTriggerFor]="menu">
    <!-- *ngIf="selectedRows.length > 0" -->
    {{ "MENU" | translate }}
  </button>
  <mat-menu #menu="matMenu">
    <button
      mat-menu-item
      (click)="openPopupDialog()"
      [disabled]="getPermission('PP_AREA_PROGRAM#COMMISSION')"
    >
      {{ "COMMISSIONED" | translate }}
    </button>
    <button
      mat-menu-item
      (click)="openPopupDialog()"
      [disabled]="getPermission('PP_AREA_PROGRAM#EXPANSION')"
    >
      {{ "EXPANSION.START" | translate }}
    </button>
    <button
      mat-menu-item
      (click)="openPopupDialog()"
      [disabled]="getPermission('PP_AREA_PROGRAM#CANCEL')"
    >
      {{ "CANCEL" | translate }}
    </button>
  </mat-menu>
</div>


Solution

  • Reasons why I could not get it passed

    • Ngonchanges on the child component does not get triggered if pass complex object
    • so it stuck at the dashboard (parent component) and does not get passed to the child component

    https://i.sstatic.net/NRjIa.png

    - workaround is to pass the subscribe object
    
    • another reason why it was not passed:

      We have this view:

      • Dashboard
        • ActionButton
        • Milestone
          • Table
        • SupplierSearch
          • Table

      I have been passing through table -> mileston -> dashboard -> actionbutton

      but I have been selecting rows on the SupplierSearch view of the table on the ui. thus it has never been passed to the Milestone from the Table

    Workaround

    • pass the array up to the parent most component (Dashboard) with event emitters

    • then create the Subject$ (observable) two broadcast the complex data to the child component

      A Subject is like an Observable, but can multicast to many Observers. Subjects are like EventEmitters: they maintain a registry of many listeners. — source rxjs

      code snippet

      dashboard.ts
      
      selectedRows$ = new Subject<any[]>();
      
      onNotify(rowsEmitted: any[]): void {
          console.log('from Dashboard onNotify ');
          this.selectedRows = rowsEmitted;
          this.selectedRowsCount = rowsEmitted.length;
          console.log(this.selectedRows);
          this.selectedRows$.next(rowsEmitted);
        }
      
      • on notify is the last function in the chain to pass the array up to the parent component
      • then it create the anonymous observer and subscribe (next())
    • Subject selectedRows$ then will be passed to the child component action button

      dashboard.html
      
      <app-action-button [selectedRows]="selectedRows$">
      </app-action-button>
      
    • it will the create the anonymous observer and subscribe

      action-button.ts
      
      ngOnInit(): void {
          // this.router.navigate([backUrl]);
          console.log('from action button component ');
          console.log(this.selectedRows);
          this.selectedRows.subscribe((selectedArray) =>
            console.log('from action button ngOnInit: ' + selectedArray)
      );