Search code examples
javascripthtmlangularhtml-tablengfor

How To dynamically generate an HTML Table using ngFor. in Angular


I am trying to dynamically generate the following html table, as seen on the screenshot enter image description here

I was able to manually create the table using dummy data, but my problem is that I am trying to combine multiple data sources in order to achieve this HTML table structure.

SEE STACKBLITZ for the full example.

The Data looks like this (focus on the activities field):

    let data = {

id: '60bf06e6fc8f613117de1db9',
    activities: {
      '0': ['Power', 4, '', 2.5, '', 0, ''],
      '1': ['Attitude', 2, '', 3, '', 0, ''],
      '2': ['NR', 4.5, '', 1.5, '', 0, ''],
      '3': ['FMS', 4, '', 4, '', 0, ''],
      '4': ['Automation', 2.5, '', 2.5, '', 0, ''],
      '5': ['Path', 4.5, '', 2.5, '', 0, ''],
      '6': ['Systems', 2, '', 2.5, '', 0, ''],
      '7': ['Environment', 4.5, '', 2.5, '', 0, ''],
      '8': ['Planning', 2, '', 2.5, '', 0, ''],
      '9': ['Co-ordinate', 4.5, '', 3, '', 0, ''],
      '10': ['Prioritize', 2.5, '', 3, '', 0, ''],
      '11': ['Workload', 4.5, '', 2.5, '', 0, ''],
      '12': ['Crew', 4, '', 3, '', 0, ''],
      '13': ['ATC', 2.5, '', 3, '', 0, ''],
      '14': ['Identify', 4, '', 2.5, '', 0, ''],
      '15': ['Ass. Risk', 2, '', 4.5, '', 0, ''],
      '16': ['Checklist', 4, '', 2.5, '', 0, ''],
      '17': ['Analysis', 3, '', 3, '', 0, '']
    }}

The activities field has a total of 18 activities. Each one is identified by its id, and an array of activities. For instance '0': ['Power', 4, '', 2.5, '', 0, ''],. '0' represents the id of the activity Power. 4 represents Attempt1 - Grade & '' represents Attempt1 - Note; etc (Refer to the screenshot for clarification).

The complete list of activities is stored in a different variable/file and has the following structure.

Component.ts

        this.activities = [{
                "id": 0,
                "activity": "Power",
                "subject": "Control",
                "icon": "icon-link"
            },
            {
                "id": 1,
                "activity": "Attitude",
                "subject": "Control",
                "icon": "icon-link"
            },{...}]
    this.groupedActivities = customGroupByFunction(this.activities, 'subject');
this.colors = {Control: '#bfbfbf', 'AFCS': '#bfbfbf', ...}
    this.activitiesListKey = Object.keys(this.activitiesList);

Below is my html code.

        <table>
        <caption>Session Summary</caption>
        <tr>
          <th style="width: 40%"></th>
          <th style="width: auto"></th>
          <!-- Loop through AttemptCount variable
            to populate this heading -->
          <th style="width: auto" colspan="2" *ngFor="let attempt of attemptCounts(3)">Attempt {{attempt}}</th>
    
        </tr>
    
        <tr>
          <th style="width: 40%">Subject Grouping</th>
          <th style="width: auto"></th>
    
          <!-- Populate these heading using the formula
            AttemptCount * 2 (columns - Grade & Note) -->
          <ng-container *ngFor="let attemptLabel of attemptCounts(3)">
    
            <th style="width: auto">Grade</th>
            <th style="width: auto">Note</th>
          </ng-container>
        </tr>
    
        <!-- Loop through all Activity Subjects
        And create heading accordingly. 6 Main Subjects so far -->
    
        <!-- Next Subject -->
    
        <ng-container *ngFor="let id of activitiesListKey">
          <tr>
    
            <!-- Generate [Rowspan] = (Subject.Array.Length + 1) -->
            <th style="width: 40%;" rowspan="4" [style.background]="colors[id]">
              {{id}}</th>
    
            <!-- Loop through each Subject.Array Elements
            and populate -->
          <tr style="width: auto">
            <th>Power</th>
            <td>2.6</td>
            <td>Can Improve</td>
            <td>5.0</td>
            <td>Excellent</td>
            <td>4.5</td>
            <td>Regressed</td>
          </tr>
          <tr style="width: auto">
            <th>Attitude</th>
            <td>4.0</td>
            <td>Fantastic</td>
            <td>4.5</td>
            <td>Getting Better</td>
            <td>5.0</td>
            <td>Nice</td>
          </tr>
          <tr style="width: auto">
            <th>NR</th>
            <td>2.6</td>
            <td>Can Improve</td>
            <td>5.0</td>
            <td>Excellent</td>
            <td>4.5</td>
            <td>Regressed</td>
          </tr>
          <!-- </tr> -->
        </ng-container>
    
      </table>

N.B: attemptCounts(n) is simply a function that returns an array of n elements. for example attemptCounts(3) will return [0,1,2]

I am willing to change the structure of my data.activities if it is going to make the table easier to generate. So please if anyone has a solution that works with a different data model, please do share.

the values "Getting Better", "Excellent" are entered by the instructor from a form field. So they could be constants or not. Or even just empty strings. That will work as well.

Can someone please help me dynamically generate this table? I have been struggling with this for days now, and I seem not able to find a solution that works for me.

SO, Please help a friend in distress.


Solution

  • One possible way to make it work without changing the data structure.

    First some Interfaces:

    interface Activity {
      id: number;
      activity: string;
      subject: string;
      icon: string;
    }
    
    interface Attempt {
      grade: number;
      note: string;
    }
    
    interface ActivityAttemp {
      activityName: string;
      attempts: Attempt[];
    }
    
    interface ActivitiesBySubject {
      subject: string;
      activities: ActivityAttemp[];
    }
    

    Calculate Attempts count from data.activities:

    attemptCounts: number[] = [];
    
    const count =
      ((Object.values(this.data.activities)[0] as string[]).length - 1) / 2;
    for (let i = 1; i <= count; ++i) {
      this.attemptCounts.push(i);
    }
    

    Before giving data to Angular to render it some pre-processing:

    this.subjects.forEach((subject: string) => {
      this.activitiesBySubject.push({
        subject,
        activities: this.activities
          .filter((act: Activity) => act.subject === subject)
          .map((act: Activity) => {
            return {
              activityName: act.activity,
              attempts: this.getAttemptsForActivity(act.activity)
            };
          })
      });
    });
    

    The idea is to have all needed data in one place this way HTML template becomes much simpler:

    <div style="overflow: auto">
      <table>
        <caption>Session Summary</caption>
        <tr>
          <th style="width: 40%"></th>
          <th style="width: auto"></th>
          <th style="width: auto" colspan="2" *ngFor="let attempt of attemptCounts">Attempt {{attempt}}</th>
        </tr>
    
        <tr>
          <th style="width: 40%">Subject Grouping</th>
          <th style="width: auto">Activity</th>
    
          <ng-container *ngFor="let attemptLabel of attemptCounts">
            <th style="width: auto">Grade</th>
            <th style="width: auto">Note</th>
          </ng-container>
        </tr>
    
    
        <ng-container *ngFor="let actsBySubj of activitiesBySubject">
    
          <tr>
            <td [attr.rowspan]="actsBySubj.activities.length + 1">
              {{ actsBySubj.subject }}
            </td>
          </tr>
    
          <tr *ngFor="let activity of actsBySubj.activities">
            <td>
              {{ activity.activityName }}
            </td>
    
            <ng-container *ngFor="let attempt of activity.attempts">
    
              <td>
                {{attempt.grade}}
              </td>
              <td>
                {{attempt.note}}
              </td>
    
            </ng-container>
    
          </tr>
    
        </ng-container>
    
      </table>
    </div>
    

    Working Stackblitz

    Thanks @GaurangDhorda for initial Stackblitz.

    UPDATE

    To work with dropdown the processing has to be moved to a separate method:

      private calcTableData() {
        // reset
        this.attemptCounts = [];
        this.activitiesBySubject = [];
    
        // find exercise from "selectedExerciseId"
        const selectedExercise = this.data.find(
          (x: any) => x.id === this.selectedExerciseId
        );
    
        if (!selectedExercise) {
          return; // unable to find exercise
        }
    
        // calc attempt count eg.: 1, 2, 3
        for (let i = 1; i <= selectedExercise.attemptCount; ++i) {
          this.attemptCounts.push(i);
        }
    
        this.subjects.forEach((subject: string) => {
          this.activitiesBySubject.push({
            subject,
            activities: this.activities
              .filter((act: Activity) => act.subject === subject)
              .map((act: Activity) => {
                return {
                  activityName: act.activity,
                  attempts: this.getAttemptsForActivity(
                    selectedExercise,
                    act.activity
                  )
                };
              })
          });
        });
    
        console.log(this.activitiesBySubject);
      }
    

    This method uses a member variable selectedExerciseId to get the selected item. Also the color palette is moved to:

      colors: { [key: string]: string } = {
        Control: '#bfbfbf',
        AFCS: '#fac090',
        'Situational Awareness': '#c4bd97',
        'Leadership / Teamwork': '#d99694',
        Communication: '#c3d69b',
        'PB. Solving / Decision Making': '#b3a2c7'
      };
    

    Event handler onChange and life cycle hook ngOnit just calls calcTableData:

      ngOnInit(): void {
        this.calcTableData();
      }
    
      onChange(e: Event) {
        this.calcTableData();
      }
    

    Updated Stackblitz