From 9e8fe6e3fa8e3136494c8bc35e127d5a0554cc53 Mon Sep 17 00:00:00 2001 From: mrkvon Date: Thu, 15 Mar 2018 14:34:18 +0100 Subject: [PATCH 1/3] vote up or down for an idea --- .../ideas/read-idea/read-idea.component.html | 33 ++++++++----- .../ideas/read-idea/read-idea.component.scss | 19 +++++++ .../read-idea/read-idea.component.spec.ts | 10 +++- .../ideas/read-idea/read-idea.component.ts | 25 +++++++++- src/app/model.service.spec.ts | 49 ++++++++++++++++++- src/app/model.service.ts | 41 +++++++++++++++- src/app/shared/types.ts | 9 +++- src/app/shared/vote/vote.component.html | 14 ++++-- src/app/shared/vote/vote.component.scss | 17 +++++++ src/app/shared/vote/vote.component.spec.ts | 5 ++ src/app/shared/vote/vote.component.ts | 17 ++++++- 11 files changed, 215 insertions(+), 24 deletions(-) diff --git a/src/app/ideas/read-idea/read-idea.component.html b/src/app/ideas/read-idea/read-idea.component.html index 7e0f747..f72b1ee 100644 --- a/src/app/ideas/read-idea/read-idea.component.html +++ b/src/app/ideas/read-idea/read-idea.component.html @@ -1,19 +1,28 @@ - - -

lightbulb_outline {{idea.title}}

-
- -
-
- -
-
- -
+
+
+ lightbulb_outline + +
+
+

{{idea.title}}

+
+ +
+
+ +
+
+ +
+
+
diff --git a/src/app/ideas/read-idea/read-idea.component.scss b/src/app/ideas/read-idea/read-idea.component.scss index 8f3d806..e22311c 100644 --- a/src/app/ideas/read-idea/read-idea.component.scss +++ b/src/app/ideas/read-idea/read-idea.component.scss @@ -1,3 +1,22 @@ +@import "../../shared/styles/colors"; + .idea-navigation { float: right; } + +.idea-wrapper { + display: flex; +} + +.side-container { + text-align: center; + margin-right: 0.7em; + margin-top: 33px; +} + +.type-icon { + font-size: 32px; + width: 32px; + height: 32px; + color: $darker; +} diff --git a/src/app/ideas/read-idea/read-idea.component.spec.ts b/src/app/ideas/read-idea/read-idea.component.spec.ts index 1a4380b..a9eb8e2 100644 --- a/src/app/ideas/read-idea/read-idea.component.spec.ts +++ b/src/app/ideas/read-idea/read-idea.component.spec.ts @@ -8,11 +8,14 @@ import { ReadIdeaComponent } from './read-idea.component'; import { EditorOutputComponent } from 'app/shared/editor-output/editor-output.component'; import { MaterialModule } from 'app/material.module'; import { AuthService } from 'app/auth.service'; +import { ModelService } from 'app/model.service'; import { UserSmallStubComponent } from 'app/shared/user-small/user-small.component'; import { CommentsStubComponent } from 'app/comments/comments.component'; @Component({ selector: 'app-vote', template: '' }) -class VoteStubComponent { } +class VoteStubComponent { + @Input() votes; +} @Component({ selector: 'app-tag-list', template: '' }) class TagListStubComponent { @@ -23,6 +26,8 @@ class AuthStubService { username = 'user'; } +class ModelStubService { } + class ActivatedRouteStub { data = Observable.of({ idea: { id: '123', creator: { username: 'user' } }, @@ -50,7 +55,8 @@ describe('ReadIdeaComponent', () => { ], providers: [ { provide: AuthService, useClass: AuthStubService }, - { provide: ActivatedRoute, useClass: ActivatedRouteStub } + { provide: ActivatedRoute, useClass: ActivatedRouteStub }, + { provide: ModelService, useClass: ModelStubService } ] }) .compileComponents(); diff --git a/src/app/ideas/read-idea/read-idea.component.ts b/src/app/ideas/read-idea/read-idea.component.ts index b9013ed..b29beee 100644 --- a/src/app/ideas/read-idea/read-idea.component.ts +++ b/src/app/ideas/read-idea/read-idea.component.ts @@ -1,8 +1,9 @@ import { Component, OnInit } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { AuthService } from '../../auth.service'; -import { Comment, Idea, Tag } from '../../shared/types'; +import { AuthService } from 'app/auth.service'; +import { ModelService } from 'app/model.service'; +import { Comment, Idea, Tag } from 'app/shared/types'; @Component({ selector: 'app-read-idea', @@ -18,6 +19,7 @@ export class ReadIdeaComponent implements OnInit { constructor(private auth: AuthService, + private model: ModelService, private route: ActivatedRoute) { } ngOnInit() { @@ -31,4 +33,23 @@ export class ReadIdeaComponent implements OnInit { }); } + async onVote(vote: number) { + // when vote doesn't exist, we add it + // when vote exists, we remove it + // and we update the idea.votes object + if (this.idea.votes.me === 0) { + // we add the vote + await this.model.vote({ to: { type: 'ideas', id: this.idea.id }, value: vote }); + + if (vote === 1) { this.idea.votes.up += 1; } + if (vote === -1) { this.idea.votes.down += 1; } + this.idea.votes.me = vote; + } else { + // we remove the vote + await this.model.vote({ to: { type: 'ideas', id: this.idea.id }, value: 0 }); + if (this.idea.votes.me === 1) { this.idea.votes.up -= 1; } + if (this.idea.votes.me === -1) { this.idea.votes.down -= 1; } + this.idea.votes.me = 0; + } + } } diff --git a/src/app/model.service.spec.ts b/src/app/model.service.spec.ts index 6c935e1..cececfd 100644 --- a/src/app/model.service.spec.ts +++ b/src/app/model.service.spec.ts @@ -1659,6 +1659,11 @@ describe('ModelService', () => { type: 'users', id: 'test-user' } } + }, + meta: { + votesUp: 5, + votesDown: 3, + myVote: 0 } }, included: [ @@ -1680,7 +1685,8 @@ describe('ModelService', () => { detail: 'test detail', creator: { username: 'test-user' - } + }, + votes: { up: 5, down: 3, me: 0 } }); })); }); @@ -2139,6 +2145,47 @@ describe('ModelService', () => { })); }); + describe('vote({ to: { type, id }, value })', () => { + it('when value is +1 or -1 add the vote', async(async () => { + const votePromise = service.vote({ to: { type: 'ideas', id: '123456'}, value: -1 }); + const req = httpMock.expectOne(`${baseUrl}/ideas/123456/votes`); + expect(req.request.method).toEqual('POST'); + expect(req.request.headers.has('authorization')).toEqual(true); + expect(req.request.body).toEqual({ + data: { + type: 'votes', + attributes: { + value: -1 + } + } + }); + + req.flush({ + data: { + type: 'votes', + id: '112233', + attributes: { + value: -1, + created: 1234567890 + } + } + }); + + await votePromise; + })); + + it('when value is 0 remove the vote', async(async () => { + const votePromise = service.vote({ to: { type: 'ideas', id: '123456'}, value: 0 }); + const req = httpMock.expectOne(`${baseUrl}/ideas/123456/votes/vote`); + expect(req.request.method).toEqual('DELETE'); + expect(req.request.headers.has('authorization')).toEqual(true); + + req.flush(null); + + await votePromise; + })); + }); + // verify that there are no outstanding requests remaining afterEach(() => { httpMock.verify(); diff --git a/src/app/model.service.ts b/src/app/model.service.ts index 209e025..7d293c1 100644 --- a/src/app/model.service.ts +++ b/src/app/model.service.ts @@ -11,7 +11,7 @@ import 'rxjs/add/observable/of'; import * as _ from 'lodash'; -import { Comment, Idea, Tag, User, UserTag, Message, Contact } from './shared/types'; +import { Comment, Idea, Tag, User, UserTag, Message, Contact, Votes } from './shared/types'; import { AuthService } from './auth.service'; @Injectable() @@ -938,6 +938,30 @@ export class ModelService { return this.deserializeComment(response.data); } + /** + * Vote + * When we provide 0, the current vote will be deleted. + * Otherwise a new vote will be created. + */ + public async vote({ to: { type, id }, value }: { to: { type: string, id: string }, value: number }): Promise { + + if (value === 0) { + await this.http + .delete(`${this.baseUrl}/${type}/${id}/votes/vote`, { headers: this.loggedHeaders }).toPromise(); + + } else { + const requestBody = { + data: { + type: 'votes', + attributes: { value } + } + }; + + await this.http + .post(`${this.baseUrl}/${type}/${id}/votes`, requestBody, { headers: this.loggedHeaders }).toPromise(); + } + } + private deserializeIdeaTag(ideaTagData: any): Tag { return this.deserializeTag(ideaTagData.relationships.tag.data); } @@ -972,10 +996,15 @@ export class ModelService { private deserializeIdea(ideaData: any, included?: any[]): Idea { - const { id, attributes: { title, detail }, relationships } = ideaData; + const { id, attributes: { title, detail }, meta, relationships } = ideaData; const idea: Idea = { id, title, detail }; + // add votes + if (meta && meta.hasOwnProperty('votesUp')) { + idea.votes = this.deserializeVotes(meta); + } + // add creator if (relationships && relationships.creator && included) { const creatorUsername = relationships.creator.data.id; @@ -1090,4 +1119,12 @@ export class ModelService { return { user, tag, story, relevance } as UserTag; } + + private deserializeVotes(rawVotes: any): Votes { + return { + up: rawVotes.votesUp, + down: rawVotes.votesDown, + me: rawVotes.myVote + }; + } } diff --git a/src/app/shared/types.ts b/src/app/shared/types.ts index d7a4e01..62942fd 100644 --- a/src/app/shared/types.ts +++ b/src/app/shared/types.ts @@ -38,7 +38,14 @@ export class Idea { public title: string, public detail: string, public creator?: User, - public tags?: Tag[]) { } + public tags?: Tag[], + public votes?: Votes) { } +} + +export class Votes { + constructor(public up: number, + public down: number, + public me: number) { } } export class Contact { diff --git a/src/app/shared/vote/vote.component.html b/src/app/shared/vote/vote.component.html index 388ff10..397ffe8 100644 --- a/src/app/shared/vote/vote.component.html +++ b/src/app/shared/vote/vote.component.html @@ -1,6 +1,14 @@
- -
123
- + +
{{voteSum}}
+
diff --git a/src/app/shared/vote/vote.component.scss b/src/app/shared/vote/vote.component.scss index e69de29..c0d43fa 100644 --- a/src/app/shared/vote/vote.component.scss +++ b/src/app/shared/vote/vote.component.scss @@ -0,0 +1,17 @@ +@import "../../shared/styles/colors"; + +.vote-button { + color: gray; + background-color: white; + padding: 0.1em; + + &.button-voted { + color: $darker; + } +} + +.vote-sum { + text-align: center; + color: gray; + font-size: 1.2em; +} diff --git a/src/app/shared/vote/vote.component.spec.ts b/src/app/shared/vote/vote.component.spec.ts index eb34dc1..05275c9 100644 --- a/src/app/shared/vote/vote.component.spec.ts +++ b/src/app/shared/vote/vote.component.spec.ts @@ -20,6 +20,11 @@ describe('VoteComponent', () => { beforeEach(() => { fixture = TestBed.createComponent(VoteComponent); component = fixture.componentInstance; + component.votes = { + up: 1, + down: 1, + me: 0 + }; fixture.detectChanges(); }); diff --git a/src/app/shared/vote/vote.component.ts b/src/app/shared/vote/vote.component.ts index 917316c..4c84a77 100644 --- a/src/app/shared/vote/vote.component.ts +++ b/src/app/shared/vote/vote.component.ts @@ -1,4 +1,5 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Votes } from 'app/shared/types'; @Component({ selector: 'app-vote', @@ -7,9 +8,23 @@ import { Component, OnInit } from '@angular/core'; }) export class VoteComponent implements OnInit { + @Input() votes: Votes; + @Output() vote = new EventEmitter(); + constructor() { } ngOnInit() { } + get voteSum() { + return this.votes.up - this.votes.down; + } + + public onClickUp() { + this.vote.emit(1); + } + + public onClickDown() { + this.vote.emit(-1); + } } From fdae43d50f914680613ad2d309a1257a808535c8 Mon Sep 17 00:00:00 2001 From: mrkvon Date: Thu, 15 Mar 2018 19:37:46 +0100 Subject: [PATCH 2/3] vote for comments additionally styling, refactor --- .../comments/comment/comment.component.html | 105 ++++++++++-------- .../comments/comment/comment.component.scss | 9 ++ .../comment/comment.component.spec.ts | 4 +- src/app/comments/comment/comment.component.ts | 19 ++++ .../ideas/idea-form/idea-form.component.scss | 3 + .../read-idea/read-idea.component.spec.ts | 6 +- src/app/model.service.spec.ts | 8 +- src/app/model.service.ts | 7 ++ src/app/shared/types.ts | 3 +- src/app/shared/vote/vote.component.scss | 3 +- src/app/shared/vote/vote.component.ts | 5 + src/app/testing.module.ts | 4 +- 12 files changed, 118 insertions(+), 58 deletions(-) diff --git a/src/app/comments/comment/comment.component.html b/src/app/comments/comment/comment.component.html index 2916269..3d6ce93 100644 --- a/src/app/comments/comment/comment.component.html +++ b/src/app/comments/comment/comment.component.html @@ -1,66 +1,75 @@
- -
- -
+
+ + +
-
+
+ +
+ +
- - - {{comment.created | amTimeAgo}} +
- + + + {{comment.created | amTimeAgo}} - - + - - - - - - + + - - - Really delete? - + - + (click)="editComment(true)" + *ngIf="isCreatorMe">edit + + + - + *ngIf="isCreatorMe" (click)="askReallyDelete(true)">delete - -
+ + + Really delete? + + + + + - -
- -
+ +
- -
-
    -
  • - -
  • -
-
+ +
+ +
+ +
+
    +
  • + +
  • +
+
+
diff --git a/src/app/comments/comment/comment.component.scss b/src/app/comments/comment/comment.component.scss index 9d1a44d..830c613 100644 --- a/src/app/comments/comment/comment.component.scss +++ b/src/app/comments/comment/comment.component.scss @@ -2,6 +2,7 @@ margin: 0.5em 0; padding: 1em; border: 1px solid gray; + display: flex; } .comment-content { @@ -12,3 +13,11 @@ font-size: smaller; color: gray; } + +.vote-wrapper { + margin-right: 1em; +} + +.body-wrapper { + flex: 1; +} diff --git a/src/app/comments/comment/comment.component.spec.ts b/src/app/comments/comment/comment.component.spec.ts index 273b3d0..46b7e55 100644 --- a/src/app/comments/comment/comment.component.spec.ts +++ b/src/app/comments/comment/comment.component.spec.ts @@ -2,6 +2,7 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; import { MomentModule } from 'angular2-moment'; import { CommentComponent } from './comment.component'; +import { VoteStubComponent } from 'app/shared/vote/vote.component'; import { CommentFormStubComponent } from '../comment-form/comment-form.component'; import { EditorOutputComponent } from 'app/shared/editor-output/editor-output.component'; import { UserSmallStubComponent } from 'app/shared/user-small/user-small.component'; @@ -21,7 +22,8 @@ describe('CommentComponent', () => { CommentComponent, CommentFormStubComponent, EditorOutputComponent, - UserSmallStubComponent + UserSmallStubComponent, + VoteStubComponent ], imports: [ MomentModule diff --git a/src/app/comments/comment/comment.component.ts b/src/app/comments/comment/comment.component.ts index 3a9e572..76790f9 100644 --- a/src/app/comments/comment/comment.component.ts +++ b/src/app/comments/comment/comment.component.ts @@ -105,6 +105,25 @@ export class CommentComponent implements OnInit { _.pullAllBy(this.comment.reactions, [reaction], 'id'); } + async onVote(vote: number) { + // when vote doesn't exist, we add it + // when vote exists, we remove it + // and we update the comment.votes object + if (this.comment.votes.me === 0) { + // we add the vote + await this.model.vote({ to: { type: 'comments', id: this.comment.id }, value: vote }); + + if (vote === 1) { this.comment.votes.up += 1; } + if (vote === -1) { this.comment.votes.down += 1; } + this.comment.votes.me = vote; + } else { + // we remove the vote + await this.model.vote({ to: { type: 'comments', id: this.comment.id }, value: 0 }); + if (this.comment.votes.me === 1) { this.comment.votes.up -= 1; } + if (this.comment.votes.me === -1) { this.comment.votes.down -= 1; } + this.comment.votes.me = 0; + } + } } /** diff --git a/src/app/ideas/idea-form/idea-form.component.scss b/src/app/ideas/idea-form/idea-form.component.scss index e69de29..8bd6bef 100644 --- a/src/app/ideas/idea-form/idea-form.component.scss +++ b/src/app/ideas/idea-form/idea-form.component.scss @@ -0,0 +1,3 @@ +input { + width: 100%; +} diff --git a/src/app/ideas/read-idea/read-idea.component.spec.ts b/src/app/ideas/read-idea/read-idea.component.spec.ts index a9eb8e2..3054729 100644 --- a/src/app/ideas/read-idea/read-idea.component.spec.ts +++ b/src/app/ideas/read-idea/read-idea.component.spec.ts @@ -11,11 +11,7 @@ import { AuthService } from 'app/auth.service'; import { ModelService } from 'app/model.service'; import { UserSmallStubComponent } from 'app/shared/user-small/user-small.component'; import { CommentsStubComponent } from 'app/comments/comments.component'; - -@Component({ selector: 'app-vote', template: '' }) -class VoteStubComponent { - @Input() votes; -} +import { VoteStubComponent } from 'app/shared/vote/vote.component'; @Component({ selector: 'app-tag-list', template: '' }) class TagListStubComponent { diff --git a/src/app/model.service.spec.ts b/src/app/model.service.spec.ts index cececfd..994e73c 100644 --- a/src/app/model.service.spec.ts +++ b/src/app/model.service.spec.ts @@ -2016,6 +2016,11 @@ describe('ModelService', () => { relationships: { creator: { data: { type: 'users', id: 'user1' } }, primary: { data: { type: 'ideas', id: '111' } } + }, + meta: { + votesUp: 5, + votesDown: 4, + myVote: -1 } } ], @@ -2030,7 +2035,8 @@ describe('ModelService', () => { id: '112233', content: 'comment content', created: 1234, - creator: { username: 'user1' } + creator: { username: 'user1' }, + votes: { up: 5, down: 4, me: -1 } }]); })); }); diff --git a/src/app/model.service.ts b/src/app/model.service.ts index 7d293c1..1886d73 100644 --- a/src/app/model.service.ts +++ b/src/app/model.service.ts @@ -991,6 +991,13 @@ export class ModelService { comment.reactions = reactions; } + const { meta } = commentData; + + // add votes + if (meta && meta.hasOwnProperty('votesUp')) { + comment.votes = this.deserializeVotes(meta); + } + return comment; } diff --git a/src/app/shared/types.ts b/src/app/shared/types.ts index 62942fd..03cc059 100644 --- a/src/app/shared/types.ts +++ b/src/app/shared/types.ts @@ -186,5 +186,6 @@ export class Comment { public creator: User, public created: number, public content: string, - public reactions?: Comment[]) { } + public reactions?: Comment[], + public votes?: Votes) { } } diff --git a/src/app/shared/vote/vote.component.scss b/src/app/shared/vote/vote.component.scss index c0d43fa..0f3c7a5 100644 --- a/src/app/shared/vote/vote.component.scss +++ b/src/app/shared/vote/vote.component.scss @@ -6,7 +6,8 @@ padding: 0.1em; &.button-voted { - color: $darker; + background-color: $darker; + color: white; } } diff --git a/src/app/shared/vote/vote.component.ts b/src/app/shared/vote/vote.component.ts index 4c84a77..50cfd7c 100644 --- a/src/app/shared/vote/vote.component.ts +++ b/src/app/shared/vote/vote.component.ts @@ -28,3 +28,8 @@ export class VoteComponent implements OnInit { this.vote.emit(-1); } } + +@Component({ selector: 'app-vote', template: '' }) +export class VoteStubComponent { + @Input() votes; +} diff --git a/src/app/testing.module.ts b/src/app/testing.module.ts index 25610ee..9b80bf4 100644 --- a/src/app/testing.module.ts +++ b/src/app/testing.module.ts @@ -8,13 +8,15 @@ import { CommentStubComponent } from './comments/comment/comment.component'; import { CommentsStubComponent } from './comments/comments.component'; import { CommentFormStubComponent } from './comments/comment-form/comment-form.component'; import { UserSmallStubComponent } from './shared/user-small/user-small.component'; +import { VoteStubComponent } from './shared/vote/vote.component'; @NgModule({ declarations: [ CommentStubComponent, CommentsStubComponent, CommentFormStubComponent, - UserSmallStubComponent + UserSmallStubComponent, + VoteStubComponent ] }) export class TestingModule { } From 540af87bcd74be831dc8ea489c65536f7087e7d1 Mon Sep 17 00:00:00 2001 From: mrkvon Date: Thu, 15 Mar 2018 20:43:43 +0100 Subject: [PATCH 3/3] fix missing votes when adding new comment --- src/app/model.service.spec.ts | 8 +++++++- src/app/model.service.ts | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/app/model.service.spec.ts b/src/app/model.service.spec.ts index 994e73c..5656153 100644 --- a/src/app/model.service.spec.ts +++ b/src/app/model.service.spec.ts @@ -1975,6 +1975,11 @@ describe('ModelService', () => { relationships: { creator: { data: { type: 'users', id: 'user1' } }, primary: { data: { type: 'ideas', id: '00001' } } + }, + meta: { + votesUp: 0, + votesDown: 0, + myVote: 0 } }, included: [ @@ -1988,7 +1993,8 @@ describe('ModelService', () => { id: '112233', content: 'comment content', created: 1234, - creator: { username: 'user1' } + creator: { username: 'user1' }, + votes: { up: 0, down: 0, me: 0 } }); })); }); diff --git a/src/app/model.service.ts b/src/app/model.service.ts index 1886d73..7d35f13 100644 --- a/src/app/model.service.ts +++ b/src/app/model.service.ts @@ -908,7 +908,10 @@ export class ModelService { const response: any = await this.http .post(`${this.baseUrl}/${type}/${id}/${comments}`, requestBody, { headers: this.loggedHeaders }).toPromise(); - return this.deserializeComment(response.data); + const newComment = this.deserializeComment(response.data); + // quick fix of backend which doesn't include meta votes info + newComment.votes = newComment.votes || { up: 0, down: 0, me: 0 }; + return newComment; } /**