Comments in Angular - Building Nested Comments with Replies
Comments in Angular - Building Nested Comments with Replies

In this post we will implement comments inside Angular as a separate module. And actually the comment feature is really difficult. This is exactly why I want to show you how to do that inside Angular correctly.

Project example

Generated project

So here I already generated for us an Angular application. And it is completely empty. And the only thing that is external here is a styles.css file where all global classes for our module are written.

/* src/styles.css */
.comments {
  margin-top: 20px;
}

.comments-title {
  font-size: 30px;
  margin-bottom: 20px;
}

.comments-container {
  margin-top: 40px;
}

.comment-form-title {
  font-size: 22px;
}

.comment-form-textarea {
  width: 100%;
  height: 80px;
  margin-bottom: 8px;
  margin-top: 8px;
  border: 1px solid rgb(107, 114, 12);
}

.comment-form-button {
  font-size: 16px;
  padding: 8px 16px;
  background: rgb(59, 130, 246);
  border-radius: 8px;
  color: white;
}

.comment-form-button:hover:enabled {
  cursor: pointer;
  background: rgb(37, 99, 235);
}

.comment-form-button:disabled {
  opacity: 0.7;
  cursor: default;
}

.comment-form-cancel-button {
  margin-left: 10px;
}

.comment {
  display: flex;
  margin-bottom: 28px;
}

.comment-image-container {
  margin-right: 12px;
}

.comment-image-container img {
  border-radius: 50px;
}

.comment-right-part {
  width: 100%;
}

.comment-content {
  display: flex;
}

.comment-author {
  margin-right: 8px;
  font-size: 20px;
  color: rgb(59, 130, 246);
}

.comment-text {
  font-size: 18px;
}

.comment-actions {
  display: flex;
  font-size: 12px;
  color: rgb(51, 51, 51);
  cursor: pointer;
  margin-top: 8px;
}

.comment-action {
  margin-right: 8px;
}

.comment-action:hover {
  text-decoration: underline;
}

.replies {
  margin-top: 20px;
}

This is why you can just take this styles so you should not retype them and we can fully focus on Angular.

Planning

So how we will implement this feature? We will create an additional module for the whole comments. Why is that? Because actually we can reuse comments on every single page. We can write comments for the post, profile or other entity.

Which means that it must be completely sharable and configurable.

This is what I want to do here is to create a new folder comments inside src/app.

Binding API

Also I want to make our implementation as realistic as possible. For this we will use a tool which is called json-server. It allows us to create a fake API in seconds. We just need to install globally json-server package with npm and create db.json file inside our project.

{
  "comments": [
    {
      "id": "1",
      "body": "First comment",
      "username": "Jack",
      "userId": "1",
      "parentId": null,
      "createdAt": "2021-08-16T23:00:33.010+02:00"
    },
    {
      "id": "2",
      "body": "Second comment",
      "username": "John",
      "userId": "2",
      "parentId": null,
      "createdAt": "2021-08-16T23:00:33.010+02:00"
    },
    {
      "id": "3",
      "body": "First comment first child",
      "username": "John",
      "userId": "2",
      "parentId": "1",
      "createdAt": "2021-08-16T23:00:33.010+02:00"
    },
    {
      "id": "4",
      "body": "Second comment second child",
      "username": "John",
      "userId": "2",
      "parentId": "2",
      "createdAt": "2021-08-16T23:00:33.010+02:00"
    }
  ]
}

Here we created a new key comments were we wrote some default comments to work with. Now we need to install this package and start API.

npm install -g json-server
json-server --watch db.json

As you can see inside http://localhost:300/comments we get our comments that we created back. Additionally all typical requests like POST, DELETE, GET, UPDATE and PUT will work out of the box and make changes to db.json file.

Json API

Which means our API is ready and our Angular application can work with this API.

Creating module

Now let's start with creating our comments module.

// src/app/comments/comments.module.ts
@NgModule({
})
export class CommentsModule {}

Here is our new empty module. But we also should not forget to register it in app.module.ts

@NgModule({
  ...
  imports: [
    ...
    CommentsModule
  ],
})
export class AppModule {}

The next step is to create our comments. And for this application we plan 3 components:

  1. Component for the list of comments
  2. Component for the single comment
  3. Component for comment form (create comment / reply comment)

Let's create an empty list of comments, single comment and comment form.

// src/app/comments/components/comments/comments.component.ts

@Component({
  selector: 'comments',
  templateUrl: './comments.component.html',
})
export class CommentsComponent {}

// src/app/comments/components/comment/comment.component.ts

@Component({
  selector: 'comment',
  templateUrl: './comment.component.html',
})
export class CommentComponent {}

// src/app/comments/components/commentForm/commentForm.component.ts

@Component({
  selector: 'comment-form',
  templateUrl: './commentForm.component.html',
})
export class CommentFormComponent {}

We also should not forget to register our components in comments.module.ts.

@NgModule({
  declarations: [CommentsComponent, CommentComponent, CommentFormComponent]
})
export class CommentsModule {}

Now let's render our comments component in app.

<!-- app.components.html -->

<comments></comments>

Current user ID

The next thing that I want to talk about is our current user. We must provide currentUserId in our comments section. Why is that? Actually in production application we have logged in user. And typically when we are logged in we can create and update comments. Inside our current comments implementation we won't do anything about current user. This is why I want to pass inside our comments component currentUserId just like a string. This is exactly how we will do it in real application.

<!-- app.components.html -->

<comments currentUserId="1"></comments>

In this case our comments component won't know anything about current user implementation and will just get ID as an input.

// src/app/comments/components/comments/comments.component.ts

@Component({
  selector: 'comments',
  templateUrl: './comments.component.html',
})
export class CommentsComponent {
  @Input() currentUserId: string | undefined;
}

So here we provided currentUserId as a string. The important point is that our currentUserId might be not always provided from outside. This is why we must mark it as undefined.

Creating interface for comment

The next thing that we need is to create an interface for our comment. We must leverage Typescript as we want to make our application as string as possible.

// src/app/comments/types/comment.interface.ts

export interface CommentInterface {
  id: string;
  body: string;
  username: string;
  userId: string;
  parentId: null | string;
  createdAt: string;
}

Here we provided all needed fields for comment. As you can see parentId can be either null or a string. Our root comments won't have a parent this is why parentId will be null there. All our nested comments will have a parentId so we know were this comment belongs.

Creating service

The next thing that we want to create is our service. Because we want to fetch comments from our API.

// src/app/comments/services/comments.service.ts

@Injectable()
export class CommentsService {
  constructor(private httpClient: HttpClient) {}

  getComments(): Observable<CommentInterface[]> {
    return this.httpClient.get<CommentInterface[]>(
      'http://localhost:3000/comments'
    );
  }
}

We created our CommentsService and the first method inside is getComments which will fetch a list of comments from API. Back we get a stream of CommentInterface[]. This is why it is important to create interfaces for our entities. In this case we can type everything correctly.

Inside we used httpClient which comes from Angular to fetch data. To make it working we must also add HttpClientModule in app.module.ts. Also we should not forget to register our service in comments module.

@NgModule({
  ...
  providers: [CommentsService]
})
export class AppModule {}

Now we can use our new service inside component and fetch data.

// src/app/comments/components/comments/comments.component.ts
export class CommentsComponent implements OnInit {
  @Input() currentUserId!: string;

  comments: CommentInterface[] = [];

  constructor(private commentsService: CommentsService) {}

  ngOnInit(): void {
    this.commentsService.getComments().subscribe((comments) => {
      this.comments = comments;
    });
  }
}

So here we injected CommentsService in our comments component and called getComments inside ngOnInit. As it is a stream we subscribed to it and wrote the result inside comments property.

Comments after fetching

As you can see we successfully fetched our comments from API as in real project.

Rendering comments

Now let's render our list of comments without creating additional component.

<!-- src/app/comments/components/comments/comments.component.html -->
<div class="comments">
  <h3 class="comments-title">Comments</h3>
  <div class="comment-form-title">Write comment</div>
  COMMENT FORM
  <div class="comments-container">
    <div *ngFor="let comment of comments">
      {{comment.body}}
    </div>
  </div>
</div>

So this is just simple markup where we render comments inside a div.

Now let's render instead of div a comment component where we will pass comment inside.

// src/app/comments/components/comment/comment.component.ts
export class CommentComponent {
  @Input() comment!: CommentInterface;
}
<!-- src/app/comments/components/comment/comment.component.html -->
{{comment.body}}

We can use comment component instead of div.

<!-- src/app/comments/components/comments/comments.component.html -->
...
<div class="comments-container">
  <comment
    *ngFor="let comment of comments"
    [comment]="comment"
  >
  </comment>
</div>
...

Basic rendered comments

As you can see we successfully rendered comments with additional comment component. But obviously our component markup doesn't look good and we must fix it.

Markup for the comment component

<!-- src/app/comments/components/comment/comment.component.html -->
<div class="comment">
  <div class="comment-image-container">
    <img src="/assets/user-icon.png" />
  </div>
  <div class="comment-right-part">
    <div class="comment-content">
      <div class="comment-author">{{ comment.username }}</div>
    </div>
    <div class="comment-text">{{ comment.body }}</div>
  </div>
</div>

Here is just markup of our comment with property from our comment object.

Basic rendered comments with images

Creating comment form

Now I want to continue with comment form because it will help us to create new comments. What inputs do we need in our comments form?

  • Initial text (for editing form)
  • Cancel button (to cancel reply or edit)
  • Submit label (label on button "add", "reply", "update")
// src/app/comments/components/commentForm/commentForm.component.ts
export class CommentFormComponent implements OnInit {
  @Input() submitLabel!: string;
  @Input() hasCancelButton: boolean = false;
  @Input() initialText: string = '';

  form!: FormGroup;

  constructor(private fb: FormBuilder) {}

  ngOnInit(): void {
    this.form = this.fb.group({
      title: [this.initialText, Validators.required],
    });
  }

  onSubmit(): void {
    console.log('onSubmit', this.form.value)
  }
}

Here we passed 3 new inputs inside our CommentFormComponent. We also injected FormBuilder inside we create new reactive form. As we have just a single title property we created it and passed initialText as default value inside.

Now it's time to add our markup.

<!-- src/app/comments/components/commentForm/commentForm.component.html -->
<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <textarea class="comment-form-textarea" formControlName="title"></textarea>
  <button class="comment-form-button" type="submit" [disabled]="form.invalid">
    {{ submitLabel }}
  </button>
  <button
    *ngIf="hasCancelButton"
    type="button"
    class="comment-form-button comment-form-cancel-button"
  >
    Cancel
  </button>
</form>

Now we can use our CommentFormComponent inside our CommentsComponent.

<!-- src/app/comments/components/comments/comments.component.html -->
...
<h3 class="comments-title">Comments</h3>
<div class="comment-form-title">Write comment</div>
<comment-form
  submitLabel="Write"
>
</comment-form>
...

This is how our form is looking now.

Basic comment form

Submitting a form

Now we want to implement form submitting. For this we must create an output which will give our comment title outside.

// src/app/comments/components/commentForm/commentForm.component.ts
export class CommentFormComponent implements OnInit {
  ...
  @Output()
  handleSubmit = new EventEmitter<string>();

  ...
  onSubmit(): void {
    this.handleSubmit.emit(this.form.value.title);
    this.form.reset();
  }

Here we added handleSubmit output and emitted it with title from the form outside. We also used form.reset() to return our form to initial state.

Now we can handle it outside.

<!-- src/app/comments/components/comments/comments.component.html -->
...
<h3 class="comments-title">Comments</h3>
<div class="comment-form-title">Write comment</div>
<comment-form
  submitLabel="Write"
  (handleSubmit)="addComment({text: $event, parentId: null})"
>
</comment-form>
...

Here we added a method addComment with specific notation. We don't only want to provide text from the comment form inside but the object with parentId. We do that because when we will send data to API we must provide a valid parentId or null.

// src/app/comments/components/comments/comments.component.ts
export class CommentsComponent implements OnInit {
  ...
  addComment({
    text,
    parentId,
  }: {
    text: string;
    parentId: string | null;
  }): void {
    console.log('addComment', text, parentId)
  }
}

As you can see in browser our comment is logged and it is working.

Basic form propagation

Saving a comment

Now it's time to save our comment that we created. This is why we want to add new method to our CommentsService.

export class CommentsService {
  constructor(private httpClient: HttpClient) {}

  ...

  createComment(
    text: string,
    parentId: string | null = null
  ): Observable<CommentInterface> {
    return this.httpClient.post<CommentInterface>(
      'http://localhost:3000/comments',
      {
        body: text,
        parentId,
        // Should not be set here
        createdAt: new Date().toISOString(),
        userId: '1',
        username: 'John',
      }
    );
  }
}

Here we created createComment which gets text and parentId. It return stream with created comment and we make POST request inside. The important thing here is that in real application we would provide only body and parentId inside and backend will add createdAt, userId, username on it's own. We use a fake API here which won't return such fields for us this is why we set there here.

createdAt, userId, username are here ONLY because of fake API

Now we can use our API method in comments component.

// src/app/comments/components/comments/comments.component.ts
...
addComment({
  text,
  parentId,
}: {
  text: string;
  parentId: string | null;
}): void {
  this.commentsService
    .createComment(text, parentId)
    .subscribe((createdComment) => {
      this.comments = [...this.comments, createdComment];
    });
}

Here we used our method from commentsService and got createdComment back. After this we updated array with our comments by using spread operator.

Adding new comment

As you can see in browser we successfully created a comment and rendered it on the page.

Actions for comment

We want to implement create, update, delete buttons now. First of all let's add them to the markup.

<!-- src/app/comments/components/comment/comment.component.html -->
<div class="comment">
  <div class="comment-image-container">
    <img src="/assets/user-icon.png" />
  </div>
  <div class="comment-right-part">
    <div class="comment-content">
      <div class="comment-author">{{ comment.username }}</div>
      <div>{{ createdAt }}</div>
    </div>
    <div class="comment-text">{{ comment.body }}</div>
    COMMENT FORM EDITING
    <div class="comment-actions">
      <div
        *ngIf="canReply"
        class="comment-action"
      >
        Reply
      </div>
      <div
        *ngIf="canEdit"
        class="comment-action"
      >
        Edit
      </div>
      <div
        *ngIf="canDelete"
        class="comment-action"
      >
        Delete
      </div>
    </div>
    COMMENT FORM REPLYING & REPLIES
  </div>
</div>

As you see I used canReply, canEdit, canDelete that we must implement now

// src/app/comments/components/comment/comment.component.js
export class CommentComponent implements OnInit {
  @Input() currentUserId!: string;

  createdAt: string = '';
  canReply: boolean = false;
  canEdit: boolean = false;
  canDelete: boolean = false;

  ngOnInit(): void {
    const fiveMinutes = 300000;
    const timePassed =
      new Date().getMilliseconds() -
        new Date(this.comment.createdAt).getMilliseconds() >
      fiveMinutes;
    this.canReply = Boolean(this.currentUserId);
    this.canEdit = this.currentUserId === this.comment.userId && !timePassed;
    this.canDelete =
      this.currentUserId === this.comment.userId &&
      this.replies.length === 0 &&
      !timePassed;
  }

This is a lot of code!

  • First of all we created canReply, canEdit, canDelete and setted them to false.
  • We can reply to the comment when we are logged in. This is why we check if we have currentUserId in canReply. But in order to get it we must provide currentUserId in the input.
  • We want to allow canEdit and canDelete only first 5 minutes this is why we created fiveMinutes and timePassed property
  • We also don't want to allow deleting a comment if it has replies. But in order to do this we must implement replies which we will do in a second

Now we must get replies as an input for the comment. We also must write logic to calculate them in our CommentsComponent.

// src/app/comments/components/comment/comment.component.js
export class CommentComponent implements OnInit {
  ...
  @Input() replies: CommentInterface[]
}

Now we must provide our replies from outside.

<!-- src/app/comments/components/comments/comments.component.html -->
...
<div class="comments-container">
  <comment
    *ngFor="let comment of comments"
    [comment]="comment"
    [currentUserId]="currentUserId"
    [replies]="getReplies(comment.id)"
  >
  </comment>
</div>

We call a getReplies function where we provide parentId inside. Let's create this function.

src/app/comments/components/comments/comments.component.ts
...
getReplies(commentId: string): CommentInterface[] {
  return this.comments
    .filter((comment) => comment.parentId === commentId)
    .sort(
      (a, b) =>
        new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
    );
}
...

We just took our this.comments and filtered them by commentId. So we find all child comments here. We also sorted them in ascending order by createdAt property.

As you can see in browser our action buttons are there.

Action buttons

Active comment

Now we need to think about active comment. And actually when we click reply button or edit button we need to render our comment form in this specific place. So actually we want to activate showing form for specific comment. This is why we need to store activeComment. This will give us enough information to render a comment form.

For this I want to create additional interface and an enum.

// src/app/comments/types/activeCommentType.enum.ts
export enum ActiveCommentTypeEnum {
  replying = 'replying',
  editing = 'editing',
}
// src/app/comments/types/activeComment.interface.ts

import { ActiveCommentTypeEnum } from './activeCommentType.enum';

export interface ActiveCommentInterface {
  id: string;
  type: ActiveCommentTypeEnum;
}

In our active comment we need to know commentId and type which can be 'replying' or 'editing'. It is a good practice to move such constants to enum in Typescript.

Now we can create activeComment inside our CommentsComponent.

// src/app/comments/components/comments/comments.component.ts
...
export class CommentsComponent implements OnInit {
  ...
  activeComment: ActiveCommentInterface | null = null;
  ....
}

By default no comment is selected so it's null. After we select a comment to must provide an ID and type inside.

Now we must attach events to our action buttons.

<!-- src/app/comments/components/comment/comment.component.html -->
<div class="comment-actions">
  <div
    *ngIf="canReply"
    class="comment-action"
    (click)="
      setActiveComment.emit({
        id: comment.id,
        type: activeCommentType.replying
      })
    "
  >
    Reply
  </div>
  <div
    *ngIf="canEdit"
    class="comment-action"
    (click)="
      setActiveComment.emit({
        id: comment.id,
        type: activeCommentType.editing
      })
    "
  >
    Edit
  </div>
  ...
</div>

Here we added setActiveComment output in our CommentsComponent. We provided inside id and type exactly like we need per definition.

// src/app/comments/components/comment/comment.component.ts

export class CommentComponent implements OnInit {
  ...
  @Output()
  setActiveComment = new EventEmitter<ActiveCommentInterface | null>();

  activeCommentType = ActiveCommentTypeEnum;
}

We registered setActiveComment emitter and activeCommentType because it is the only way to use our enum in html.

Now we must handle setActiveComment in our CommentsComponent.

<!-- src/app/comments/components/comments/comments.component.html -->
...
<div class="comments-container">
  <comment
    ...
    (setActiveComment)="setActiveComment($event)"
  >
  </comment>
</div>

and we also must assign inside our activeComment.

// src/app/comments/components/comments/comments.component.ts

export class CommentsComponent implements OnInit {
  ...
  setActiveComment(activeComment: ActiveCommentInterface | null): void {
    this.activeComment = activeComment;
  }
}

Reply form

It is time to create our reply form in a single comment.

<!-- src/app/comments/components/comment/comment.component.html -->
<div class="comment-actions">
  ...
</div>
<comment-form
  *ngIf="isReplying()"
  submitLabel="Reply"
  (handleSubmit)="addComment.emit({ text: $event, parentId: replyId })"
></comment-form>

We used our comment-form here which we will render only when we have isReplying true. We also provided submitLabel inside and added function to handleSubmit. Which means that we must create addComment output in our CommentComponent.

// src/app/comments/components/comment/comment.component.ts
export class CommentComponent implements OnInit {
  ...
  @Output()
  addComment = new EventEmitter<{ text: string; parentId: string | null }>();

  isReplying(): boolean {
    if (!this.activeComment) {
      return false;
    }
    return (
      this.activeComment.id === this.comment.id &&
      this.activeComment.type === this.activeCommentType.replying
    );
  }
}

Here we created addComment output which has exactly the same signature like our addComment method in CommentsComponent. Inside isReplying we check if our active comment id and type equals our comment id and type in CommentComponent.

Now we need to talk about replyId. We provided it inside addComment.emit but it doesn't exist yet in our component. So replyId is the commentId where we will attach our reply. But we don't want to render nested comments indefinitely because it is really slow. This is why we will render only 2 levels of comments: root level and 1 child level. If you try to reply to a reply we will attach it as a child of root comment.

This is why here is a logic how to calculate our replyId.


// src/app/comments/components/comment/comment.component.ts
export class CommentComponent implements OnInit {
  ...
  @Input() parentId!: string | null;
  replyId: string | null = null;


  ngOnInit(): void {
    ...
    this.replyId = this.parentId ? this.parentId : this.comment.id;
  }
}

First we can get from outside parentId. And we will provide it inside ONLY if our comment is a reply and not a root comment. If our comment has a parentId we store it inside our reply. So instead of attaching a reply to current comment we will attach it to the parent. If we don't have a parent so we are inside root comment we use this.comment.id.

And the last thing that we must do here is create event emitter addComment.

// src/app/comments/components/comment/comment.component.ts
export class CommentComponent implements OnInit {
  ...
  @Output()
  addComment = new EventEmitter<{ text: string; parentId: string | null }>();
  ...
}

Now we need to provide needed data in CommentsComponent.

<!-- src/app/comments/components/comments/comments.component.html -->
...
<div class="comments-container">
  <comment
    ...
    (addComment)="addComment($event)"
  >
  </comment>
</div>

We don't need to provide parentId inside as it is a root comment.

Reply form

Which means we successfully implemented reply form and adding of child comment.

One small problem that we have is that our reply form is not closing when we hit "Reply". We can easily achieve this if we set activeComment in null again.

// src/app/comments/components/comments/comments.component.ts
...
addComment({
  text,
  parentId,
}: {
  text: string;
  parentId: string | null;
}): void {
  this.commentsService
    .createComment(text, parentId)
    .subscribe((createdComment) => {
      this.comments = [...this.comments, createdComment];
      this.activeComment = null;
    });
}

Now after we hit reply our comment form is closed again.

Rendering replies

Now it's time to render our replies. For this we first need to add markup of our replies.

<!-- src/app/comments/components/comment/comment.component.html -->
<comment-form
  *ngIf="isReplying()"
  submitLabel="Reply"
  (handleSubmit)="addComment.emit({ text: $event, parentId: replyId })"
></comment-form>
<div class="replies" *ngIf="replies.length > 0">
  <comment
    *ngFor="let reply of replies"
    [comment]="reply"
    (setActiveComment)="setActiveComment.emit($event)"
    [activeComment]="activeComment"
    (deleteComment)="deleteComment.emit($event)"
    (addComment)="addComment.emit($event)"
    [parentId]="comment.id"
    [replies]="[]"
    [currentUserId]="currentUserId"
  ></comment>

Here we looped through replies of the comment because they are already available for us through the input. Inside we recursively render comment component. It is important to provide every single input and outside inside it just like we did in CommentsComponent. The only difference is that child component will never have replies so we provide an empty array inside.

As you can see in browser, we successfully rendered the list of replies for every comment.

Replies list

Edit form

Now it's time to implement our edit form. For this we need isEditing property just like we made isReplying.

// src/app/comments/components/comment/comment.component.ts
export class CommentComponent implements OnInit {
  ...

  isEditing(): boolean {
    if (!this.activeComment) {
      return false;
    }
    return (
      this.activeComment.id === this.comment.id &&
      this.activeComment.type === this.activeCommentType.editing
    );
  }
}

Is is fully copy paste of isReplying but with editing activeCommentType inside. Let's render our editing form now.

<!-- src/app/comments/components/comment/comment.component.html -->
<div class="comment-text" *ngIf="!isEditing()">{{ comment.body }}</div>
<comment-form
  *ngIf="isEditing()"
  submitLabel="Update"
  [hasCancelButton]="true"
  [initialText]="comment.body"
  (handleSubmit)="
    updateComment.emit({ text: $event, commentId: comment.id })
  "
></comment-form>
<div class="comment-actions">

Here we put if condition to render our comment body only if it is not in editing state. We also used our comment form component to render editing form. The only thing which is missing is updateComment output that we must add.

// src/app/comments/components/comment/comment.component.ts
export class CommentComponent implements OnInit {
  ...
  @Output()
  updateComment = new EventEmitter<{ text: string; commentId: string }>();
  ...
}

Now we must provide updateComment not only in root comment but also in child comments.

<!-- src/app/comments/components/comments/comments.component.html -->
...
<div class="comments-container">
  <comment
    ...
    (updateComment)="updateComment($event)"
  >
  </comment>
</div>
<!-- src/app/comments/components/comment/comment.component.html -->
<comment
  *ngFor="let reply of replies"
  ...
  (updateComment)="updateComment.emit($event)"
></comment>

We still didn't add updateComment method to our CommentsComponent. It should make update request and then modify comment on frontend.

// src/app/comments/components/comments/comments.component.ts
...
  updateComment({
    text,
    commentId,
  }: {
    text: string;
    commentId: string;
  }): void {
    this.commentsService
      .updateComment(commentId, text)
      .subscribe((updatedComment) => {
        this.comments = this.comments.map((comment) => {
          if (comment.id === commentId) {
            return updatedComment;
          }
          return comment;
        });

        this.activeComment = null;
      });
  }
  ...

The only thing that is missing now is updateComment method inside our service.

// src/app/comments/services/comments.service.ts

@Injectable()
export class CommentsService {
  constructor(private httpClient: HttpClient) {}

  updateComment(id: string, text: string): Observable<CommentInterface> {
    return this.httpClient.patch<CommentInterface>(
      `http://localhost:3000/comments/${id}`,
      {
        body: text,
      }
    );
  }
}

We get id and text as arguments and make a patch request to update our body.

As you can see now we can update any comment.

Updating of the comment

Cancel button

But the next problem is that our cancel button logic is comment form is still not implemented. We need an emit in our form.

<!-- src/app/comments/components/commentForm/commentForm.component.html -->
...
<button
  *ngIf="hasCancelButton"
  type="button"
  class="comment-form-button comment-form-cancel-button"
  (click)="handleCancel.emit()"
>
  Cancel
</button>
...

And we must add an output for this

// src/app/comments/components/commentForm/commentForm.component.ts
export class CommentFormComponent implements OnInit {
  @Output()
  handleCancel = new EventEmitter<void>();
  ....

}

Now in our edit form we can just set active comment back to null.

<!-- src/app/comments/components/comment/comment.component.html -->
<div class="comment-text" *ngIf="!isEditing()">{{ comment.body }}</div>
<comment-form
  *ngIf="isEditing()"
  ...
  (handleSubmit)="
    updateComment.emit({ text: $event, commentId: comment.id })

  "
  (handleCancel)="setActiveComment.emit(null)"
></comment-form>
<div class="comment-actions">

Our cancel button is working now.

Deleting comment

Our last feature is deleting of the comment. It is exactly the same like update but we just need a comment id that we want to delete.

// src/app/comments/services/comments.service.ts

@Injectable()
export class CommentsService {
  constructor(private httpClient: HttpClient) {}

  deleteComment(id: string): Observable<{}> {
    return this.httpClient.delete(`http://localhost:3000/comments/${id}`);
  }
}

Here is our deleteComment method inside a service which removes comment by ID. Now we need a click handler and an output.

<!-- src/app/comments/components/comment/comment.component.html -->
<div
  *ngIf="canDelete"
  class="comment-action"
  (click)="deleteComment.emit(comment.id)"
>
  Delete
</div>
</div>

And we must add an output.

// src/app/comments/components/comment/comment.component.ts
export class CommentComponent implements OnInit {
  ...
  @Output()
  deleteComment = new EventEmitter<string>();
  ...
}

Now we need to handle this action in root comment and in child comment.

<!-- src/app/comments/components/comments/comments.component.html -->
...
<div class="comments-container">
  <comment
    ...
    (deleteComment)="deleteComment($event)"
  >
  </comment>
</div>
<!-- src/app/comments/components/comment/comment.component.html -->
<comment
  *ngFor="let reply of replies"
  ...
  (deleteComment)="deleteComment.emit($event)"
></comment>

And last but not least is to create a deleteComment function inside CommentsComponent.

// src/app/comments/components/comments/comments.component.ts
...
  deleteComment(commentId: string): void {
    this.commentsService.deleteComment(commentId).subscribe(() => {
      this.comments = this.comments.filter(
        (comment) => comment.id !== commentId
      );
    });
  }
  ...

As you can see in browser now we can remove comments. So we successfully implemented nested comments feature. If you want to know why your eyes are always tired make sure to check this post

📚 Source code of what we've done