angulartypescriptangular-materialangular-reactive-forms

Subtask is Updated Without Saving and Incorrect Icon Displayed on Reopening Task Dialog


I am encountering two issues with the subtask editing functionality in my application:

  1. When I click the "Edit" button to edit a subtask and make changes, the subtask gets updated even if I don't click the "Save" button. Instead, when I click the "Create/Update" button, the changes to the subtask are saved automatically. This behavior is unexpected since I did not explicitly save the subtask.

  2. After making changes to a subtask without saving then updating the task. After that reopening the task dialog, the save icon (diskette) is displayed instead of the edit icon (pencil). The correct behavior should display the edit icon when the task is reopened without any unsaved changes.

Here is a detailed description of the steps to reproduce these issues:

  1. Click on the "Edit" button for a subtask.
  2. Make changes to the subtask without clicking "Save".
  3. Click the "Create/Update" button.
  4. Observe that the subtask is updated with the changes even though "Save" icon was not clicked.

For the second issue:

  1. Make changes to a subtask without saving.
  2. Update the task without saving the subtask
  3. Reopen the task dialog.
  4. Notice that the diskette icon is displayed instead of the pencil icon.

I would appreciate any advice on how to address these issues.

Stackblitz Demo

main.ts

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [TaskFormComponent, CommonModule],
  template: `
  <!-- <app-task-form></app-task-form> --> 
  <br><br><br>
  <button (click)="updateTask()">Edit</button>

  <div>Updated Subtasks:</div>
  <ul>
    <li *ngFor="let subtask of this.task.subtasks">
      {{ subtask.description }}
    </li>
  </ul>

  `,
})
export class App {
  name = 'Angular';
  task: Task = {
    id: 1,
    title: 'Task 1',
    subtasks: [
      {
        id: 1,
        taskId: 1,
        description: 'Subtask 1',
        isDone: false,
        isEditable: false,
      },
      {
        id: 2,
        taskId: 1,
        description: 'Subtask 2',
        isDone: false,
        isEditable: false,
      },
    ],
  };
  constructor(private dialog: MatDialog) {}
  public updateTask() {
    this.dialog
      .open(TaskFormComponent, {
        data: { fromPopup: true, task: this.task },
      })
      .afterClosed()
      .pipe(filter((task) => task))
      .subscribe((task) => {});
  }
}

bootstrapApplication(App, {
  providers: [provideAnimations()],
}).catch((err) => console.error(err));

task-form.component.ts

export class TaskFormComponent {
  protected readonly Object = Object;
  taskForm!: FormGroup;
  fromPopup = false;
  subtasks: Subtask[] = [];
  constructor(
    private fb: FormBuilder,
    @Optional() private dialogRef: MatDialogRef<TaskFormComponent>,
    @Optional()
    @Inject(MAT_DIALOG_DATA)
    public data: { fromPopup: boolean; task: Task }
  ) {
    if (!this.data) {
      this.data = {
        fromPopup: true,
        task: {
          id: 1,
          title: '',
          subtasks: [],
        },
      };
    }
  }

  ngOnInit() {
    this.fromPopup = !!this.data?.fromPopup;

    this.taskForm = this.fb.group({
      id: this.data?.task?.id,
      title: new FormControl(''),
      subtasks: new FormControl(''),
    });

    if (this.data?.task) {
      this.taskForm.patchValue({
        id: this.data.task?.id,
        title: this.data.task.title,
      });
      this.subtasks = this.data.task.subtasks;
    }
  }

  public get subtasksFormControl() {
    return this.taskForm.get('subtasks') as FormControl;
  }

  public addSubtask() {
    const subtask = this.taskForm.get('subtasks')?.value;
    if (subtask.trim()) {
      this.subtasks.push({
        id: undefined,
        taskId: 1,
        description: subtask,
        isDone: false,
        isEditable: false,
      } as Subtask);

      this.subtasksFormControl.setValue(this.subtasks);
    }
    this.clearSubtask();
  }

  public clearSubtask() {
    this.taskForm.patchValue({ subtask: '' });
  }

  public editSubtask(index: number) {
    this.subtasks[index].isEditable = true;
  }

  public saveSubtask(index: number, subtask: Subtask) {
    this.subtasks[index].isEditable = false;
    this.subtasks[index] = subtask;
  }

  public deleteSubtask(index: number) {
    this.subtasks.splice(index, 1);
  }

  public onSubmit() {
    if (this.data?.task) {
      const taskRawValue = {
        ...this.taskForm.getRawValue(),
        subtasks: this.subtasks,
      };
      this.onUpdateTask();
    } else {
    }
    this.onReset();
  }

  public onUpdateTask() {
    const taskRawValue = {
      ...this.taskForm.getRawValue(),
      subtasks: this.subtasks,
    };
    if (this.fromPopup) {
      this.dialogRef.close(taskRawValue);
    } else {
    }
  }

  public onReset() {
    this.taskForm.reset();
    this.subtasks = [];
  }
}

task-form.component.html

<div mat-dialog-title>Create Task</div>

<mat-dialog-content>
  <form [formGroup]="taskForm" (ngSubmit)="onSubmit()">
    <mat-form-field>
      <mat-label>Title</mat-label>
      <input
        matInput
        formControlName="title"
        type="text"
        placeholder="Enter a title"
      />
    </mat-form-field>

    <mat-form-field>
      <mat-label>Subtasks</mat-label>
      <input
        matInput
        formControlName="subtasks"
        type="text"
        placeholder="Enter a subtask"
      />
      <button mat-icon-button matSuffix (click)="addSubtask()">
        <mat-icon>add</mat-icon>
      </button>
      <div class="vertical-divider" matSuffix></div>
      <button mat-icon-button matSuffix (click)="clearSubtask()">
        <mat-icon>close</mat-icon>
      </button>
      <mat-hint>Press enter to add a subtask</mat-hint>
    </mat-form-field>
  </form>
  <!-- SHOW HERE ALL SUBTASKS-->

  <div class="subtasks-container">
    @for (subtask of subtasks; track subtask){
    <div class="subtask-text">
      <span>&#8226;</span>
      <input
        [(ngModel)]="subtask.description"
        type="text"
        name="subtask"
        [readonly]="!subtask.isEditable"
        [ngClass]="{
          editable: subtask.isEditable,
          'read-only': !subtask.isEditable
        }"
      />
      <div class="subtasks-actions-container">
        @if(!subtask.isEditable) {
        <mat-icon color="primary" (click)="editSubtask($index)">edit</mat-icon>
        } @if(subtask.isEditable) {
        <mat-icon color="primary" (click)="saveSubtask($index, subtask)"
          >save</mat-icon
        >
        }
        <mat-icon color="warn" (click)="deleteSubtask($index)">delete</mat-icon>
      </div>
    </div>
    }
  </div>
</mat-dialog-content>

<mat-dialog-actions>
  <button (click)="onSubmit()" mat-raised-button color="primary" type="submit">
    Create/Update
  </button>
</mat-dialog-actions>

Solution

  • From here:

    this.dialog
      .open(TaskFormComponent, {
        data: { fromPopup: true, task: this.task },
      })
    

    you are sharing the reference of this.task into the MatDialogData, which results in whatever changes made in TaskFormComponent, will reflect your parent component (App).

    You should solve it with a deep copy.

    Pre-requisites: Install loadash

    import * as _ from 'lodash';
    
    public updateTask() {
      this.dialog
        .open(TaskFormComponent, {
          data: { fromPopup: true, task: _.cloneDeep(this.task) },
        })
        .afterClosed()
        .pipe(filter((task) => task))
        .subscribe((task) => (this.task = task));
    }
    

    Also, don't forget to re-assign the changed value to task.


    Updated

    You save the latest (changed) data without checking the isEditable status. Before saving and the modal close, you should check the isEditable status and reflect the original value for those subtasks which are not saved (by index/position). Besides, you should need a deep copy for the subtasks to retain the original value from the MAT_DIALOG_DATA.

    import * as _ from 'lodash';
    
    ngOnInit() {
      ...
    
      if (this.data?.task) {
        this.taskForm.patchValue({
          id: this.data.task?.id,
          title: this.data.task.title,
        });
        this.subtasks = _.cloneDeep(this.data.task.subtasks);
      }
    }
    
    public deleteSubtask(index: number) {
      this.subtasks.splice(index, 1);
      this.data.task.subtasks.splice(index, 1);
    }
    
    public onSubmit() {
      if (this.data?.task) {
        const taskRawValue = {
          ...this.taskForm.getRawValue(),
          subtasks: this.subtasks.map((x, i) =>
            x.isEditable ? (this.data.task.subtasks.at(i) ?? x) : x
          ),
        };
    
        this.onUpdateTask();
      } else {
      }
      this.onReset();
    }
    
    public onUpdateTask() {
      const taskRawValue = {
        ...this.taskForm.getRawValue(),
        subtasks: this.subtasks.map((x, i) =>
          x.isEditable ? (this.data.task.subtasks.at(i) ?? x) : x
        ),
      };
      if (this.fromPopup) {
        this.dialogRef.close(taskRawValue);
      } else {
      }
    }
    

    Issue(s) & Concern(s)

    Issue 1: I noticed that you have a typo error for the control name which results in the form control's value is not clear after adding the sub-tasks. Correct it as below:

    public clearSubtask() {
      this.taskForm.patchValue({ subtasks: '' });
    }
    

    Issue 2: The default behavior of <button> element is type="submit". As the Add and Close button for subtasks form control within the form, this will submit the form and close the modal. To avoid it, you have to add the type="button" attribute to the <button> elements.

    <button mat-icon-button matSuffix (click)="addSubtask()" type="button">
      <mat-icon>add</mat-icon>
    </button>
    
    <button mat-icon-button matSuffix (click)="clearSubtask()" type="button">
      <mat-icon>close</mat-icon>
    </button>
    

    Demo @ StackBlitz