htmlangularrecursion

How to create a recursive, n-level nested task list, where users can add subtasks within tasks, and display it correctly in the UI?


I want to implement a recursive list structure to support nested sub-notes of arbitrary depth (n-levels)? I'm working on a to-do task list assignment, where I want to enable users to create subtasks within tasks, recursively. While I've managed to achieve this up to 3 levels, I'm experiencing issues adding subtasks beyond the 4th level, despite seeing the array elements being added in the console logs at every click but nothing changes on UI.

  <div class="note-container">
  <h2>Note-Taking App</h2>

   <!-- Add Note Form -->
   <div class="add-note">
    <input type="text" placeholder="Note Title" #noteTitle />
   <textarea placeholder="Note Content" #noteContent></textarea>
   <button (click)="addNote(noteTitle.value, noteContent.value)">
   Add Note
   </button>
   </div>

  <!-- List of Notes -->
   <div class="note" *ngFor="let note of notes">
  <h3>{{ note.title }}</h3>
  <p>{{ note.content }}</p>

  <!-- Add Subtask -->
  <div>
  <input type="text" placeholder="Add Subtask" #subtaskInput />
  <button (click)="addSubtask(note, subtaskInput.value)">+</button>
  </div>

   <!-- Subtask List -->
    <ul>
    <ng-container *ngFor="let subtask of note.subtasks">
    <li>
      <input
        type="checkbox"
        [checked]="subtask.completed"
        (change)="toggleSubtask(subtask)"
      />
      <span [class.completed]="subtask.completed">{{ subtask.text }}</span>
      <button (click)="deleteSubtask(note, subtask.id)">❌</button>

      <!-- Nested Subtask Input -->
      <div>
        <input type="text" placeholder="Add Subtask" #nestedSubtask />
        <button (click)="addSubtask(subtask, nestedSubtask.value)">
          +
        </button>
      </div>

      <!-- Recursive Subtask List -->
      <ul>
        <ng-container *ngFor="let nested of subtask.subtasks">
          <li>
            <input
              type="checkbox"
              [checked]="nested.completed"
              (change)="toggleSubtask(nested)"
            />
            <span [class.completed]="nested.completed">{{
              nested.text
            }}</span>
            <input type="text" placeholder="Add Subtask" #nestedD />
            <button (click)="addSubtask(nested, nestedD.value)">+</button>
            <button (click)="deleteSubtask(subtask, nested.id)">❌</button>
          </li>
        </ng-container>
      </ul>
    </li>
    </ng-container>
   </ul>

  <button (click)="deleteNote(note.id)">Delete Note</button>
  </div>
 </div>

TS File

export class AppComponent {
notes: Note[] = [];

addNote(title: string, content: string) {
if (!title.trim() || !content.trim()) return;

this.notes.push({
  id: Date.now(),
  title,
  content,
  subtasks: [],
  });
 }

addSubtask(parent: { subtasks: Subtask[] }, text: string) {
console.log(parent.subtasks);
console.log(text);
if (!text.trim()) return;
console.log(text);
parent.subtasks.push({
  id: Date.now(),
  text,
  completed: false,
  subtasks: [], // Support nested subtasks
});
}

toggleSubtask(subtask: Subtask) {
subtask.completed = !subtask.completed;
}

deleteSubtask(parent: { subtasks: Subtask[] }, subtaskId: number) {
parent.subtasks = parent.subtasks.filter(
  (subtask) => subtask.id !== subtaskId
);
}

deleteNote(id: number) {
this.notes = this.notes.filter((note) => note.id !== id);
}
}

Console

As shown in the picture, I have clicked the (+) Button twice, on UI itb still shows one subtask box whereas in console 2 items are shown in array


Solution

  • To write recursion you can follow the below process.

    Identify the initial templates:

    List Template:

    Here it will be the for loop that runs over the property subtasks, it will show either a task or subtask and below it show the children of that particular element.

    Here notice that we are passing a property call parent, which will be either a task, subtask or note this is because when we delete, we need the parent element to delete it from the subtasks array.

    To distinguish between task and subtask I use the property type, you can also merge these two into a single template.

    We call the sample template within the template (recursion) so that it can be any level of subtasks.

        <ng-template #taskListTemplate let-parent="parent" let-type="type">
            <ng-container *ngIf="parent?.subtasks?.length">
                <ul>
                    <ng-container *ngFor="let task of parent.subtasks">
                        <li>
                            <ng-container *ngIf="type === 'task'">
                                <ng-container
                                    *ngTemplateOutlet="
                                        taskTemplate;
                                        context: { task: task, parent: parent }
                                    "
                                ></ng-container>
                            </ng-container>
                            <ng-container *ngIf="type === 'subtask'">
                                <ng-container
                                    *ngTemplateOutlet="
                                        subTaskTemplate;
                                        context: { subtask: task, parent: parent }
                                    "
                                ></ng-container>
                            </ng-container>
    
                            <ng-container
                                *ngTemplateOutlet="
                                    taskListTemplate;
                                    context: { parent: task, type: 'subtask' }
                                "
                            ></ng-container>
                        </li>
                    </ng-container>
                </ul>
            </ng-container>
        </ng-template>
    

    TASK/SUBTASK Template:

    We can split the logic of tasks and subtasks into two templates and call them from inside our list template.

        <ng-template #taskTemplate let-task="task" let-parent="parent">
            <input type="checkbox" [checked]="task.completed" (change)="toggleSubtask(task)" />
            <span [class.completed]="task.completed">{{ task.text }}</span>
            <button (click)="deleteSubtask(parent, task.id)">❌</button>
    
            <!-- Nested Subtask Input -->
            <div>
                <input type="text" placeholder="Add Subtask" #nestedSubtask />
                <button (click)="addSubtask(task, nestedSubtask.value)">+</button>
            </div>
        </ng-template>
        <ng-template #subTaskTemplate let-subtask="subtask" let-parent="parent">
            <input
                type="checkbox"
                [checked]="subtask.completed"
                (change)="toggleSubtask(subtask)"
            />
            <span [class.completed]="subtask.completed">{{ subtask.text }}</span>
            <input type="text" placeholder="Add Subtask" #nestedD />
            <button (click)="addSubtask(subtask, nestedD.value)">+</button>
            <button (click)="deleteSubtask(parent, subtask.id)">❌</button>
        </ng-template>
    

    Calling the list template once (recursion takes care of the rest):

    Now we can call this template initially using the note as the parent. It will recursively create the lists.

        <div>
            <input type="text" placeholder="Add Subtask" #subtaskInput />
            <button (click)="addSubtask(note, subtaskInput.value)">+</button>
        </div>
        <ng-container
            *ngTemplateOutlet="taskListTemplate; context: { parent: note, type: 'task' }"
        ></ng-container>
    

    Full Code:

    import { Component } from '@angular/core';
    import { bootstrapApplication } from '@angular/platform-browser';
    import { CommonModule } from '@angular/common';
    
    @Component({
      selector: 'app-root',
      imports: [CommonModule],
      template: `
    <div class="note-container">
      <h2>Note-Taking App</h2>
    
      <!-- Add Note Form -->
      <div class="add-note">
        <input type="text" placeholder="Note Title" #noteTitle />
        <textarea placeholder="Note Content" #noteContent></textarea>
        <button (click)="addNote(noteTitle.value, noteContent.value)">Add Note</button>
      </div>
    
      <!-- List of Notes -->
      <div class="note" *ngFor="let note of notes">
        <h3>{{ note.title }}</h3>
        <p>{{ note.content }}</p>
    
        <!-- Add Subtask -->
        <div>
          <input type="text" placeholder="Add Subtask" #subtaskInput />
          <button (click)="addSubtask(note, subtaskInput.value)">+</button>
        </div>
        <ng-container
          *ngTemplateOutlet="taskListTemplate; context: { parent: note, type: 'task' }"
        ></ng-container>
        <button (click)="deleteNote(note.id)">Delete Note</button>
        <ng-template #taskTemplate let-task="task" let-parent="parent">
          <input type="checkbox" [checked]="task.completed" (change)="toggleSubtask(task)" />
          <span [class.completed]="task.completed">{{ task.text }}</span>
          <button (click)="deleteSubtask(parent, task.id)">❌</button>
    
          <!-- Nested Subtask Input -->
          <div>
            <input type="text" placeholder="Add Subtask" #nestedSubtask />
            <button (click)="addSubtask(task, nestedSubtask.value)">+</button>
          </div>
        </ng-template>
        <ng-template #subTaskTemplate let-subtask="subtask" let-parent="parent">
          <input
            type="checkbox"
            [checked]="subtask.completed"
            (change)="toggleSubtask(subtask)"
          />
          <span [class.completed]="subtask.completed">{{ subtask.text }}</span>
          <input type="text" placeholder="Add Subtask" #nestedD />
          <button (click)="addSubtask(subtask, nestedD.value)">+</button>
          <button (click)="deleteSubtask(parent, subtask.id)">❌</button>
        </ng-template>
        <ng-template #taskListTemplate let-parent="parent" let-type="type">
          <ng-container *ngIf="parent?.subtasks?.length">
            <ul>
              <ng-container *ngFor="let task of parent.subtasks">
                <li>
                  <ng-container *ngIf="type === 'task'">
                    <ng-container
                      *ngTemplateOutlet="
                        taskTemplate;
                        context: { task: task, parent: parent }
                      "
                    ></ng-container>
                  </ng-container>
                  <ng-container *ngIf="type === 'subtask'">
                    <ng-container
                      *ngTemplateOutlet="
                        subTaskTemplate;
                        context: { subtask: task, parent: parent }
                      "
                    ></ng-container>
                  </ng-container>
    
                  <ng-container
                    *ngTemplateOutlet="
                      taskListTemplate;
                      context: { parent: task, type: 'subtask' }
                    "
                  ></ng-container>
                </li>
              </ng-container>
            </ul>
          </ng-container>
        </ng-template>
      </div>
    </div>
    
      `,
    })
    export class App {
      notes: any[] = [];
    
      addNote(title: string, content: string) {
        if (!title.trim() || !content.trim()) return;
    
        this.notes.push({
          id: Date.now(),
          title,
          content,
          subtasks: [],
        });
      }
    
      addSubtask(parent: { subtasks: any[] }, text: string) {
        console.log(parent.subtasks);
        console.log(text);
        if (!text.trim()) return;
        console.log(text);
        parent.subtasks.push({
          id: Date.now(),
          text,
          completed: false,
          subtasks: [], // Support nested subtasks
        });
      }
    
      toggleSubtask(subtask: any) {
        subtask.completed = !subtask.completed;
      }
    
      deleteSubtask(parent: { subtasks: any[] }, subtaskId: number) {
        parent.subtasks = parent.subtasks.filter(
          (subtask) => subtask.id !== subtaskId
        );
      }
    
      deleteNote(id: number) {
        this.notes = this.notes.filter((note) => note.id !== id);
      }
    }
    
    bootstrapApplication(App);
    

    Stackblitz Demo