Skip to content

Commit 0441583

Browse files
authored
FE - Article favorite/follow/comment actions (#18)
1 parent e2ec259 commit 0441583

File tree

13 files changed

+267
-60
lines changed

13 files changed

+267
-60
lines changed
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { UserProfile } from "./profile.model";
2+
3+
export interface ArticleComment {
4+
id: number;
5+
createdAt: string;
6+
updatedAt: string;
7+
body: string;
8+
author: UserProfile;
9+
}
10+
11+
export interface ArticleCommentsResponse {
12+
comments: ArticleComment[];
13+
}
14+
15+
export interface CreateArticleCommentPayload {
16+
comment: {
17+
body: string;
18+
};
19+
}
20+
21+
export interface ArticleCommentResponse {
22+
comment: ArticleComment;
23+
}

frontend/src/app/common/services/api/article.service.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import {
88
UpdateArticlePayload
99
} from "../../models/api/article.model";
1010
import { Observable } from "rxjs";
11+
import {
12+
ArticleCommentResponse,
13+
ArticleCommentsResponse,
14+
CreateArticleCommentPayload
15+
} from "../../models/api/comment.model";
1116

1217
@Injectable({
1318
providedIn: 'root'
@@ -41,4 +46,24 @@ export class ArticleService {
4146
public queryFeedArticles(params: QueryArticlesParams): Observable<ArticlesResponse> {
4247
return this._requestHelper.get('/articles/feed', {params});
4348
}
49+
50+
public favoriteArticle(slug: string): Observable<ArticleResponse> {
51+
return this._requestHelper.post(`/articles/${ slug }/favorite`, null);
52+
}
53+
54+
public unfavoriteArticle(slug: string): Observable<ArticleResponse> {
55+
return this._requestHelper.delete(`/articles/${ slug }/favorite`);
56+
}
57+
58+
public queryArticleComments(articleSlug: string): Observable<ArticleCommentsResponse> {
59+
return this._requestHelper.get(`/articles/${ articleSlug }/comments`);
60+
}
61+
62+
public createArticleComment(articleSlug: string, payload: CreateArticleCommentPayload): Observable<ArticleCommentResponse> {
63+
return this._requestHelper.post(`/articles/${ articleSlug }/comments`, payload);
64+
}
65+
66+
public deleteArticleComment(articleSlug: string, commentId: number): Observable<void> {
67+
return this._requestHelper.delete(`/articles/${ articleSlug }/comments/${ commentId }`);
68+
}
4469
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<div class="row">
2+
<div class="col-xs-12 col-md-8 offset-md-2">
3+
<form class="card comment-form">
4+
<div class="card-block">
5+
<textarea [formControl]="newCommentControl" class="form-control" placeholder="Write a comment..." rows="3">
6+
</textarea>
7+
</div>
8+
<div class="card-footer">
9+
<img [src]="currentUser?.image || DEFAULT_PROFILE_IMAGE" class="comment-author-img"/>
10+
<button type="button" class="btn btn-sm btn-primary" (click)="addComment()">Post Comment</button>
11+
</div>
12+
</form>
13+
14+
<ng-container *ngIf="comments">
15+
<ng-container *ngFor="let comment of comments" [ngTemplateOutlet]="singleComment"
16+
[ngTemplateOutletContext]="{ $implicit: comment }"></ng-container>
17+
</ng-container>
18+
19+
</div>
20+
</div>
21+
22+
23+
<ng-template #singleComment let-comment>
24+
<div class="card">
25+
<div class="card-block">
26+
<p class="card-text">
27+
{{ comment.body }}
28+
</p>
29+
</div>
30+
<div class="card-footer">
31+
<a routerLink="/profile/{{comment.author.username}}" class="comment-author">
32+
<img [src]="comment.author.image || DEFAULT_PROFILE_IMAGE" class="comment-author-img"/>
33+
</a>
34+
&nbsp;
35+
<a routerLink="/profile/{{comment.author.username}}" class="comment-author">{{ comment.author.username }}</a>
36+
<span class="date-posted">{{ comment.createdAt | date }}</span>
37+
38+
<!-- Allow the user to delete their own comments-->
39+
<span class="mod-options" *ngIf="comment.author.username === currentUser?.username">
40+
<i class="ion-trash-a" (click)="deleteComment(comment.id)"></i>
41+
</span>
42+
</div>
43+
</div>
44+
</ng-template>

frontend/src/app/pages/article/article-comment/article-comments.component.scss

Whitespace-only changes.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
//
3+
// import { ArticleCommentsComponent } from './article-comments.component';
4+
//
5+
// describe('ArticleCommentsComponent', () => {
6+
// let component: ArticleCommentsComponent;
7+
// let fixture: ComponentFixture<ArticleCommentsComponent>;
8+
//
9+
// beforeEach(async () => {
10+
// await TestBed.configureTestingModule({
11+
// declarations: [ArticleCommentsComponent]
12+
// })
13+
// .compileComponents();
14+
//
15+
// fixture = TestBed.createComponent(ArticleCommentsComponent);
16+
// component = fixture.componentInstance;
17+
// fixture.detectChanges();
18+
// });
19+
//
20+
// it('should create', () => {
21+
// expect(component).toBeTruthy();
22+
// });
23+
// });
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Component } from '@angular/core';
2+
import { ArticleService } from "../../../common/services/api/article.service";
3+
import { ArticleComment } from "../../../common/models/api/comment.model";
4+
import { ActivatedRoute } from "@angular/router";
5+
import { AuthenticationService } from "../../../common/services/utils/authentication.service";
6+
import { FormControl } from "@angular/forms";
7+
import { DEFAULT_PROFILE_IMAGE } from "../../../common/constants/default.constant";
8+
import { User } from "../../../common/models/api/user.model";
9+
10+
@Component({
11+
selector: 'app-article-comments',
12+
templateUrl: './article-comments.component.html',
13+
styleUrl: './article-comments.component.scss'
14+
})
15+
export class ArticleCommentsComponent {
16+
public readonly DEFAULT_PROFILE_IMAGE = DEFAULT_PROFILE_IMAGE;
17+
public currentUser?: User;
18+
public comments: ArticleComment[] = [];
19+
public newCommentControl = new FormControl('');
20+
21+
private _articleSlug: string;
22+
23+
constructor(
24+
private readonly _activatedRoute: ActivatedRoute,
25+
private readonly _articleService: ArticleService,
26+
private readonly _authService: AuthenticationService
27+
) {
28+
this._articleSlug = this._activatedRoute.snapshot.params['slug'];
29+
this._loadComments();
30+
this._getCurrentUser();
31+
}
32+
33+
private _loadComments(): void {
34+
this._articleService.queryArticleComments(this._articleSlug)
35+
.subscribe((response) => {
36+
this.comments = response.comments;
37+
});
38+
}
39+
40+
private _getCurrentUser(): void {
41+
this._authService.currentUser$.subscribe(user => {
42+
this.currentUser = user || undefined;
43+
});
44+
}
45+
46+
public deleteComment(commentId: number): void {
47+
this._articleService.deleteArticleComment(this._articleSlug, commentId)
48+
.subscribe(() => {
49+
this.comments = this.comments.filter(comment => comment.id !== commentId);
50+
});
51+
}
52+
53+
public addComment(): void {
54+
const commentBody = this.newCommentControl.value;
55+
if (!commentBody) return;
56+
57+
const payload = { comment: { body: commentBody } };
58+
59+
this._articleService.createArticleComment(this._articleSlug, payload)
60+
.subscribe((response) => {
61+
this.comments.unshift(response.comment);
62+
this.newCommentControl.reset();
63+
});
64+
}
65+
}

frontend/src/app/pages/article/article-meta/article-meta.component.html

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,33 @@
55
</div>
66

77
<ng-container *ngIf="enableUserActions">
8-
<button class="btn btn-sm btn-outline-secondary">
9-
<i class="ion-plus-round"></i>
10-
&nbsp; Follow {{ article.author.username}}
11-
</button>
12-
<button class="btn btn-sm btn-outline-primary">
13-
<i class="ion-heart"></i>
14-
&nbsp; Favorite Article <span class="counter">({{ article.favoritesCount }})</span>
15-
</button>
8+
<ng-container *ngIf="article.author.following">
9+
<button class="btn btn-sm btn-outline-danger" (click)="followed.emit(false)">
10+
<i class="ion-minus-round"></i>
11+
Unfollow {{ article.author.username }}
12+
</button>
13+
</ng-container>
14+
15+
<ng-container *ngIf="!article.author.following">
16+
<button class="btn btn-sm btn-outline-secondary" (click)="followed.emit(true)">
17+
<i class="ion-plus-round"></i>
18+
&nbsp; Follow {{ article.author.username }}
19+
</button>
20+
</ng-container>
21+
22+
<ng-container *ngIf="article.favorited">
23+
<button class="btn btn-sm btn-outline-primary" (click)="favorited.emit(false)">
24+
<i class="ion-heart"></i>
25+
&nbsp; Unfavorite Article <span class="counter">({{ article.favoritesCount }})</span>
26+
</button>
27+
</ng-container>
28+
29+
<ng-container *ngIf="!article.favorited">
30+
<button class="btn btn-sm btn-outline-primary" (click)="favorited.emit(true)">
31+
<i class="ion-heart"></i>
32+
&nbsp; Favorite Article <span class="counter">({{ article.favoritesCount }})</span>
33+
</button>
34+
</ng-container>
1635
</ng-container>
1736

1837
<ng-container *ngIf="enableAuthorActions">

frontend/src/app/pages/article/article-meta/article-meta.component.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,6 @@ export class ArticleMetaComponent {
1515
@Input() enableAuthorActions = false;
1616

1717
@Output() deleted: EventEmitter<void> = new EventEmitter();
18+
@Output() favorited: EventEmitter<boolean> = new EventEmitter();
19+
@Output() followed: EventEmitter<boolean> = new EventEmitter();
1820
}

frontend/src/app/pages/article/article.component.html

Lines changed: 4 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -25,60 +25,15 @@ <h1>{{ article.title }}</h1>
2525
<ng-container *ngTemplateOutlet="articleMeta; context:{ $implicit: article }"></ng-container>
2626
</div>
2727

28-
<div class="row">
29-
<div class="col-xs-12 col-md-8 offset-md-2">
30-
<form class="card comment-form">
31-
<div class="card-block">
32-
<textarea class="form-control" placeholder="Write a comment..." rows="3"></textarea>
33-
</div>
34-
<div class="card-footer">
35-
<img src="http://i.imgur.com/Qr71crq.jpg" class="comment-author-img" />
36-
<button class="btn btn-sm btn-primary">Post Comment</button>
37-
</div>
38-
</form>
39-
40-
<div class="card">
41-
<div class="card-block">
42-
<p class="card-text">
43-
With supporting text below as a natural lead-in to additional content.
44-
</p>
45-
</div>
46-
<div class="card-footer">
47-
<a href="/profile/author" class="comment-author">
48-
<img src="http://i.imgur.com/Qr71crq.jpg" class="comment-author-img" />
49-
</a>
50-
&nbsp;
51-
<a href="/profile/jacob-schmidt" class="comment-author">Jacob Schmidt</a>
52-
<span class="date-posted">Dec 29th</span>
53-
</div>
54-
</div>
55-
56-
<div class="card">
57-
<div class="card-block">
58-
<p class="card-text">
59-
With supporting text below as a natural lead-in to additional content.
60-
</p>
61-
</div>
62-
<div class="card-footer">
63-
<a href="/profile/author" class="comment-author">
64-
<img src="http://i.imgur.com/Qr71crq.jpg" class="comment-author-img" />
65-
</a>
66-
&nbsp;
67-
<a href="/profile/jacob-schmidt" class="comment-author">Jacob Schmidt</a>
68-
<span class="date-posted">Dec 29th</span>
69-
<span class="mod-options">
70-
<i class="ion-trash-a"></i>
71-
</span>
72-
</div>
73-
</div>
74-
</div>
75-
</div>
28+
<app-article-comments></app-article-comments>
7629
</div>
7730
</div>
7831

7932
<ng-template #articleMeta let-article>
8033
<div class="article-meta">
81-
<app-article-meta [article]="article" [enableUserActions]="!isArticleOwner" [enableAuthorActions]="isArticleOwner" (deleted)="delete()">
34+
<app-article-meta [article]="article" [enableUserActions]="!isArticleOwner" [enableAuthorActions]="isArticleOwner"
35+
(deleted)="delete()" (favorited)="toggleArticleFavorited($event)"
36+
(followed)="toggleAuthorFollowed($event)">
8237
</app-article-meta>
8338
</div>
8439
</ng-template>

frontend/src/app/pages/article/article.component.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Article } from "../../common/models/api/article.model";
33
import { ArticleService } from "../../common/services/api/article.service";
44
import { ActivatedRoute, Router } from "@angular/router";
55
import { AuthenticationService } from "../../common/services/utils/authentication.service";
6+
import { ProfileService } from "../../common/services/api/profile.service";
67

78
@Component({
89
selector: 'app-article',
@@ -19,6 +20,7 @@ export class ArticleComponent {
1920
private readonly _activatedRoute: ActivatedRoute,
2021
private readonly _articleService: ArticleService,
2122
private readonly _authService: AuthenticationService,
23+
private readonly _profileService: ProfileService,
2224
private readonly _router: Router
2325
) {
2426
this._articleSlug = this._activatedRoute.snapshot.params['slug'];
@@ -45,4 +47,32 @@ export class ArticleComponent {
4547
});
4648
}
4749
}
50+
51+
public toggleArticleFavorited(favorited: boolean): void {
52+
if (!this.article) return;
53+
54+
if (favorited) {
55+
this._articleService.favoriteArticle(this.article.slug).subscribe(response => {
56+
this.article = response.article;
57+
});
58+
} else {
59+
this._articleService.unfavoriteArticle(this.article.slug).subscribe(response => {
60+
this.article = response.article;
61+
});
62+
}
63+
}
64+
65+
public toggleAuthorFollowed(followed: boolean): void {
66+
if (!this.article) return;
67+
68+
if (followed) {
69+
this._profileService.followUser(this.article.author.username).subscribe(response => {
70+
this.article!.author = response?.profile;
71+
});
72+
} else {
73+
this._profileService.unfollowUser(this.article.author.username).subscribe(response => {
74+
this.article!.author = response?.profile;
75+
});
76+
}
77+
}
4878
}

0 commit comments

Comments
 (0)