From 2a318b16965ee50223d756fedf161edb9d710781 Mon Sep 17 00:00:00 2001 From: Enrico PIccinin Date: Fri, 28 Jun 2019 18:43:58 +0200 Subject: [PATCH 1/7] feat(conversation feature): first support for conversation --- .talismanrc | 2 +- src/app/models/vote.ts | 1 + .../conversation/conversation-routing.ts | 9 + .../conversation/conversation.module.ts | 14 ++ .../conversation/comment-card.component.ts | 28 +++ .../conversation/conversation.component.html | 32 +++ .../conversation/conversation.component.scss | 23 ++ .../conversation.component.spec.ts | 155 ++++++++++++ .../conversation/conversation.component.ts | 235 ++++++++++++++++++ src/app/modules/vote/services/vote.service.ts | 26 +- src/app/modules/vote/vote-routing.ts | 4 +- src/app/modules/vote/vote.module.ts | 13 +- src/app/modules/vote/vote/vote.component.html | 2 +- src/app/modules/vote/vote/vote.component.ts | 14 ++ src/app/services/backend.service.spec.ts | 204 ++++++++++----- src/app/services/backend.service.ts | 14 ++ src/app/services/service-names.ts | 1 + 17 files changed, 684 insertions(+), 93 deletions(-) create mode 100644 src/app/modules/conversation/conversation-routing.ts create mode 100644 src/app/modules/conversation/conversation.module.ts create mode 100644 src/app/modules/conversation/conversation/comment-card.component.ts create mode 100644 src/app/modules/conversation/conversation/conversation.component.html create mode 100644 src/app/modules/conversation/conversation/conversation.component.scss create mode 100644 src/app/modules/conversation/conversation/conversation.component.spec.ts create mode 100644 src/app/modules/conversation/conversation/conversation.component.ts diff --git a/.talismanrc b/.talismanrc index 94ec854..eeac8d3 100644 --- a/.talismanrc +++ b/.talismanrc @@ -17,7 +17,7 @@ fileignoreconfig: checksum: 510ff54745a0315fdaa5de0cf6923cc4e30081789e358120baf277f8cd1b5379 ignore_detectors: [] - filename: src/app/services/backend.service.spec.ts - checksum: dcf98ba54329a81de976728ca53eb91cbf13ac54a659422bdf0571ef237acf31 + checksum: 46591e64a9ade919c17223c375f76c56b117c3ada0ce53362970165811fb1631 ignore_detectors: [] - filename: docs/images/vote_process.gif checksum: c3a82314b7db3029e5dd9b4226bde884f472561112b4287b5193eeeab1a7cf75 diff --git a/src/app/models/vote.ts b/src/app/models/vote.ts index 302e766..c642d5c 100644 --- a/src/app/models/vote.ts +++ b/src/app/models/vote.ts @@ -2,6 +2,7 @@ import { Technology } from './technology'; import { Comment } from './comment'; export interface Vote { + _id?: string; ring: string; technology: Technology; eventRound?: any; diff --git a/src/app/modules/conversation/conversation-routing.ts b/src/app/modules/conversation/conversation-routing.ts new file mode 100644 index 0000000..60ae870 --- /dev/null +++ b/src/app/modules/conversation/conversation-routing.ts @@ -0,0 +1,9 @@ +import { Routes } from '@angular/router'; +import { ConversationComponent } from './conversation/conversation.component'; + +export const routes: Routes = [ + { + path: 'vote/conversation', + children: [{ path: '', component: ConversationComponent }] + } +]; diff --git a/src/app/modules/conversation/conversation.module.ts b/src/app/modules/conversation/conversation.module.ts new file mode 100644 index 0000000..36d1fcc --- /dev/null +++ b/src/app/modules/conversation/conversation.module.ts @@ -0,0 +1,14 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatTreeModule } from '@angular/material/tree'; + +import { AppMaterialModule } from '../../app-material.module'; + +import { ConversationComponent } from './conversation/conversation.component'; +import { CommentCardComponent } from './conversation/comment-card.component'; + +@NgModule({ + declarations: [ConversationComponent, CommentCardComponent], + imports: [CommonModule, MatTreeModule, AppMaterialModule] +}) +export class ConversationModule {} diff --git a/src/app/modules/conversation/conversation/comment-card.component.ts b/src/app/modules/conversation/conversation/comment-card.component.ts new file mode 100644 index 0000000..c350849 --- /dev/null +++ b/src/app/modules/conversation/conversation/comment-card.component.ts @@ -0,0 +1,28 @@ +import { Component, Input, Output, EventEmitter } from '@angular/core'; + +@Component({ + selector: 'byor-comment-card', + template: ` + + {{ title }} + {{ timestamp }} + {{ text }} + + + + + `, + styleUrls: ['./conversation.component.scss'], + styles: [] +}) +export class CommentCardComponent { + @Input() title: string; + @Input() timestamp: string; + @Input() text: string; + @Input() showAddReplyButton: boolean; + @Output() addNewItemClicked = new EventEmitter(); + + addNewItem() { + this.addNewItemClicked.next(); + } +} diff --git a/src/app/modules/conversation/conversation/conversation.component.html b/src/app/modules/conversation/conversation/conversation.component.html new file mode 100644 index 0000000..057b519 --- /dev/null +++ b/src/app/modules/conversation/conversation/conversation.component.html @@ -0,0 +1,32 @@ +{{voteService.technology.name}} +
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/modules/conversation/conversation/conversation.component.scss b/src/app/modules/conversation/conversation/conversation.component.scss new file mode 100644 index 0000000..6ffae81 --- /dev/null +++ b/src/app/modules/conversation/conversation/conversation.component.scss @@ -0,0 +1,23 @@ +.comment { + margin: 20px; + width: 90%; + background-color: lightgray; +} + +.new-comment { + margin: 20px; + width: 90%; +} + +.comment-card { + background-color: beige; +} + +.title { + font-weight: bold; + padding: 20px; +} + +.conversation-message { + color: darkred; +} \ No newline at end of file diff --git a/src/app/modules/conversation/conversation/conversation.component.spec.ts b/src/app/modules/conversation/conversation/conversation.component.spec.ts new file mode 100644 index 0000000..76e4377 --- /dev/null +++ b/src/app/modules/conversation/conversation/conversation.component.spec.ts @@ -0,0 +1,155 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AppMaterialModule } from '../../../app-material.module'; +import { MatTreeModule } from '@angular/material/tree'; + +import { ConversationComponent, CommentWithVoteIdNode } from './conversation.component'; +import { CommentCardComponent } from './comment-card.component'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of, asyncScheduler } from 'rxjs'; +import { observeOn, skip, take, map, tap } from 'rxjs/operators'; +import { BackendService } from 'src/app/services/backend.service'; +import { VoteService } from '../../vote/services/vote.service'; +import { Vote } from 'src/app/models/vote'; + +const TEST_TECHNOLOGIES = [ + { + id: '0001', + name: 'Babel', + quadrant: 'tools', + isnew: true, + description: 'Description of Babel' + }, + { + id: '0002', + name: 'Ember.js', + quadrant: 'languages & frameworks', + isnew: true, + description: 'Description of Ember.js' + }, + { + id: '0003', + name: 'Docker', + quadrant: 'platforms', + isnew: false, + description: 'Description of Docker' + }, + { + id: '0004', + name: 'Consumer-driven contract testing', + quadrant: 'techniques', + isnew: true, + description: 'Description of Consumer-driven contract testin' + }, + { + id: '0005', + name: 'LambdaCD', + quadrant: 'tools', + isnew: true, + description: 'Description of LambdaCD' + } +]; + +const TEST_TECHNOLOGY = { + id: '0001', + name: 'Babel', + quadrant: 'tools', + isnew: true, + description: 'Description of Babel' +}; + +const firsCommentId = 'firsCommentId'; +const replyToFirsCommentId = 'replyToFirsCommentId'; +const secondReplyToReplyToFirsCommentId = 'replyToReplyToFirsCommentId'; +class MockBackEndService { + votes: Vote[] = [ + { + technology: TEST_TECHNOLOGY, + ring: 'adopt', + comment: { + text: 'first comment', + id: firsCommentId, + author: 'Auth 1', + timestamp: '2019-06-12T17:36:19.281Z', + replies: [ + { + text: 'first reply to first comment', + id: 'replyToFirsCommentId', + author: 'Auth 1.1', + timestamp: '2019-06-12T17:46:19.281Z', + replies: [ + { + text: 'first reply to first first reply to first comment', + id: '1.1.1', + author: 'Auth 1.1.1', + timestamp: '2019-06-12T17:47:19.281Z' + }, + { + text: 'second reply to first first reply to first comment', + id: secondReplyToReplyToFirsCommentId, + author: 'Auth 1.1.2', + timestamp: '2019-06-12T17:47:29.281Z' + } + ] + } + ] + } + } + ]; + getVotesWithCommentsForTechAndEvent() { + return of(this.votes).pipe(observeOn(asyncScheduler)); + } +} +class MockVoteService { + credentials; + technology = TEST_TECHNOLOGY; + + constructor() { + this.credentials = { + voterId: null, + votingEvent: { technologies: TEST_TECHNOLOGIES, name: null, status: 'closed', _id: null, creationTS: null } + }; + } +} + +describe('ConversationComponent', () => { + let component: ConversationComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ConversationComponent, CommentCardComponent], + imports: [AppMaterialModule, MatTreeModule, HttpClientTestingModule], + providers: [{ provide: BackendService, useClass: MockBackEndService }, { provide: VoteService, useClass: MockVoteService }] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ConversationComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('find the parent nodes nodes from a node up to the top node', () => { + // _flattenedData emits when the dataSource generates a new set of flattened data + // at the second notification of this Observable we are sure that 'flatNodeMap' map + // of the ConversationComponent has been filled with the execution of 'transformer' method of ConversationComponent + // when 'flatNodeMap' is filled with data, the idsUpToFather method can work + component.dataSource._flattenedData + .pipe( + skip(1), // ignore the first notification which is emitted when the data of the data source is empty + take(1), // at the second emission 'flatNodeMap' has been filled by 'transform' method of ConversationComponent + map(() => component.idsUpToFather(secondReplyToReplyToFirsCommentId)), + tap((nodeIds) => { + expect(nodeIds.length).toBe(2); + expect(nodeIds[0]).toBe(replyToFirsCommentId); + expect(nodeIds[1]).toBe(firsCommentId); + }) + ) + .subscribe(); + }); +}); diff --git a/src/app/modules/conversation/conversation/conversation.component.ts b/src/app/modules/conversation/conversation/conversation.component.ts new file mode 100644 index 0000000..3551394 --- /dev/null +++ b/src/app/modules/conversation/conversation/conversation.component.ts @@ -0,0 +1,235 @@ +import { Component, OnDestroy } from '@angular/core'; +import { FlatTreeControl } from '@angular/cdk/tree'; +import { MatTreeFlatDataSource, MatTreeFlattener } from '@angular/material/tree'; +import { BehaviorSubject, Subscription } from 'rxjs'; + +import { Comment } from 'src/app/models/comment'; +import { BackendService } from 'src/app/services/backend.service'; +import { VoteService } from '../../vote/services/vote.service'; +import { map, switchMap, tap } from 'rxjs/operators'; +import { Vote } from 'src/app/models/vote'; +import { VoteCredentials } from 'src/app/models/vote-credentials'; + +/** Flat comment node with expandable and level information */ +export class CommentFlatNode { + text: string; + level: number; + expandable: boolean; + commentId: string; + voteId: string; + voteRing: string; + author: string; + timestamp: string; +} +export interface CommentWithVoteIdNode extends Comment { + voteId?: string; + voteRing?: string; + parentCommentId?: string; +} + +@Component({ + selector: 'byor-conversation', + templateUrl: './conversation.component.html', + styleUrls: ['./conversation.component.scss'] +}) +export class ConversationComponent implements OnDestroy { + /** Map from flat node to nested node. This helps us finding the nested node to be modified */ + flatNodeMap = new Map(); + + /** Map from nested node to flattened node. This helps us to keep the same object for selection */ + nestedNodeMap = new Map(); + + treeControl: FlatTreeControl; + + treeFlattener: MatTreeFlattener; + + dataSource: MatTreeFlatDataSource; + + comments: CommentWithVoteIdNode[]; + triggerCommentRetrieval = new BehaviorSubject(null); + commentRetrievalSubscription: Subscription; + showAddReplyButton = true; + errorMessage: string; + + constructor(private backEnd: BackendService, public voteService: VoteService) { + this.treeFlattener = new MatTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren); + this.treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); + this.dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); + + const tech = this.voteService.technology; + const votingEvent = this.voteService.credentials.votingEvent; + this.commentRetrievalSubscription = this.triggerCommentRetrieval + .pipe( + switchMap((flatNode) => + this.backEnd.getVotesWithCommentsForTechAndEvent(tech._id, votingEvent._id).pipe(map((votes: Vote[]) => ({ votes, flatNode }))) + ), + map(({ votes, flatNode }) => { + const commentsEnriched = votes.map((vote) => { + const cmt: CommentWithVoteIdNode = vote.comment; + this.setAdditionalInfoInReplies(cmt, vote); + cmt.voteRing = vote.ring; + return cmt; + }); + return { comments: commentsEnriched, flatNode }; + }) + ) + .subscribe( + ({ comments, flatNode }) => { + this.comments = comments; + this.dataSource.data = comments; + this.expandNodes(flatNode); + }, + (err) => this.setError(err.message) + ); + } + // in order to expand a child node, it is necessary to open all of the nodes parents + // https://stackoverflow.com/questions/56632492/expand-a-specific-node-of-an-angular-material-tree/56633088#56633088 + // this method finds all the nodes that, starting from flatNode, navigate the tree up to the top father comment + // and then expand all of them + expandNodes(flatNode: CommentFlatNode) { + let nodeIdsToExpand: string[] = []; + if (flatNode) { + nodeIdsToExpand = this.idsUpToFather(flatNode.commentId); + } + this.nestedNodeMap.forEach((node) => { + if (nodeIdsToExpand.indexOf(node.commentId) >= 0) { + this.treeControl.expand(node); + } + }); + } + getNodeIdsToExpand(commentId: string) { + let nodeToExpand: CommentWithVoteIdNode; + this.flatNodeMap.forEach((node) => { + if (node.id === commentId) { + nodeToExpand = node; + } + }); + return nodeToExpand; + } + idsUpToFather(commentId: string, ids?: string[]) { + const idsFound = ids ? ids : []; + const nodeToExpand = this.getNodeIdsToExpand(commentId); + const parentId = nodeToExpand.parentCommentId; + if (parentId) { + idsFound.push(parentId); + this.idsUpToFather(parentId, idsFound); + } + return idsFound; + } + + ngOnDestroy() { + this.commentRetrievalSubscription.unsubscribe(); + } + + setAdditionalInfoInReplies(cmt: CommentWithVoteIdNode, vote: Vote) { + if (cmt.replies) { + cmt.replies = cmt.replies.map((rep) => this.setAdditionalInfoInReplies(rep, vote)); + } + cmt.voteId = vote._id; + cmt.voteRing = vote.ring; + return cmt; + } + + getLevel = (node: CommentFlatNode) => node.level; + + isExpandable = (node: CommentFlatNode) => node.expandable; + + getChildren = (node: CommentWithVoteIdNode): CommentWithVoteIdNode[] => { + node.replies.forEach((rep: CommentWithVoteIdNode) => (rep.parentCommentId = node.id)); + return node.replies; + }; + + hasChild = (_: number, _nodeData: CommentFlatNode) => { + return _nodeData.expandable; + }; + + hasNoContent = (_: number, _nodeData: CommentFlatNode) => _nodeData.text === ''; + + /** + * Transformer to convert nested node to flat node. Record the nodes in maps for later use. + */ + transformer = (node: CommentWithVoteIdNode, level: number) => { + const existingNode = this.nestedNodeMap.get(node); + let flatNode = existingNode && existingNode.text === node.text ? existingNode : new CommentFlatNode(); + // create a new object if 'refresh' is true to allow a "refreshed" rendering of this element + flatNode = existingNode && existingNode['refresh'] ? { ...flatNode } : flatNode; + flatNode.text = node.text; + flatNode.level = level; + flatNode.expandable = !!node.replies; + flatNode.commentId = node.id; + flatNode.voteId = node.voteId; + flatNode.voteRing = node.voteRing; + flatNode.author = node.author; + flatNode.timestamp = node.timestamp; + this.flatNodeMap.set(flatNode, node); + this.nestedNodeMap.set(node, flatNode); + return flatNode; + }; + + /** Select the comment so we can insert the new reply. */ + addNewItem(node: CommentFlatNode) { + const parentNode = this.flatNodeMap.get(node); + parentNode.replies ? (node['refresh'] = false) : (node['refresh'] = true); + parentNode.replies = parentNode.replies ? parentNode.replies : []; + const newComment: CommentWithVoteIdNode = { + text: '', + voteId: parentNode.voteId, + voteRing: parentNode.voteRing, + parentCommentId: parentNode.id + }; + parentNode.replies.unshift(newComment); + this.dataSource.data = this.comments; + const thePotentiallyNewFlatNode = this.nestedNodeMap.get(parentNode); + this.treeControl.expand(thePotentiallyNewFlatNode); + this.showAddReplyButton = false; + } + + /** Save the node to database */ + saveComment(node: CommentFlatNode, text: string) { + this.resetError(); + if (!text || text.trim().length === 0) { + this.setError('A reply must contain some text'); + return null; + } + const nestedNode = this.flatNodeMap.get(node); + const author = this.getCommentAuthorName(this.voteService.credentials); + this.backEnd + .addReplyToVoteComment(node.voteId, { text, author }, nestedNode.parentCommentId) + .pipe( + tap(() => this.triggerCommentRetrieval.next(node)), + tap(() => (this.showAddReplyButton = true)) + ) + .subscribe({ error: (err) => this.setError(err.message) }); + } + cancelComment(node: CommentFlatNode) { + this.showAddReplyButton = true; + this.triggerCommentRetrieval.next(node); + } + + getCommentAuthorName(credentials: VoteCredentials) { + const firstName = credentials.voterId.firstName; + const lastName = credentials.voterId.lastName; + return `${firstName} ${lastName}`; + } + + getTitle(node: CommentFlatNode) { + let title = node.level > 0 ? `${node.author} - ${node.voteRing}` : node.author; + if (node.level === 0) { + title = `${node.author} - ${node.voteRing}`; + } else { + title = node.author; + } + return title; + } + getTimestamp(node: CommentFlatNode) { + const ts = new Date(node.timestamp); + return ts.toLocaleString(); + } + + setError(errorMessage: string) { + this.errorMessage = errorMessage; + } + resetError() { + this.errorMessage = null; + } +} diff --git a/src/app/modules/vote/services/vote.service.ts b/src/app/modules/vote/services/vote.service.ts index 9dab341..b0861a4 100644 --- a/src/app/modules/vote/services/vote.service.ts +++ b/src/app/modules/vote/services/vote.service.ts @@ -8,13 +8,15 @@ import { catchError, map } from 'rxjs/operators'; import { environment } from '../../../../environments/environment'; import { HttpClient } from '@angular/common/http'; import { HandleError, HttpErrorHandler } from '../../../shared/http-error-handler/http-error-handler.service'; +import { Technology } from 'src/app/models/technology'; @Injectable() export class VoteService { // TODO: Remove credentials: VoteCredentials; + technology: Technology; - private voter: { firstName: string, lastName: string }; + private voter: { firstName: string; lastName: string }; private selectedEvent: VotingEvent; url = environment.serviceUrl; @@ -25,7 +27,7 @@ export class VoteService { this.handleError = httpErrorHandler.createHandleError('VoteService'); } - setVoter(voter: { firstName: string, lastName: string }) { + setVoter(voter: { firstName: string; lastName: string }) { this.voter = voter; } @@ -44,23 +46,19 @@ export class VoteService { } }; - return this.http - .post(this.url, payload) - .pipe( - map((resp: any) => resp.data), - catchError(this.handleError(this.defaultMessage)) - ); + return this.http.post(this.url, payload).pipe( + map((resp: any) => resp.data), + catchError(this.handleError(this.defaultMessage)) + ); } getVotingEvents(): Observable> { const payload = { service: ServiceNames[ServiceNames.getVotingEvents] }; - return this.http - .post(this.url, payload) - .pipe( - map((resp: any) => resp.data), - catchError(this.handleError(this.defaultMessage)) - ); + return this.http.post(this.url, payload).pipe( + map((resp: any) => resp.data), + catchError(this.handleError(this.defaultMessage)) + ); } } diff --git a/src/app/modules/vote/vote-routing.ts b/src/app/modules/vote/vote-routing.ts index 8ce56c2..873709c 100644 --- a/src/app/modules/vote/vote-routing.ts +++ b/src/app/modules/vote/vote-routing.ts @@ -1,13 +1,15 @@ import { Routes } from '@angular/router'; import { StartVotingSessionComponent } from './start-voting-session/start-voting-session.component'; import { VoteComponent } from './vote/vote.component'; +import { ConversationComponent } from '../conversation/conversation/conversation.component'; export const routes: Routes = [ { path: 'vote', children: [ { path: '', component: StartVotingSessionComponent }, - { path: 'start', component: VoteComponent } + { path: 'start', component: VoteComponent }, + { path: 'conversation', component: ConversationComponent } ] } ]; diff --git a/src/app/modules/vote/vote.module.ts b/src/app/modules/vote/vote.module.ts index 30bd560..7007148 100644 --- a/src/app/modules/vote/vote.module.ts +++ b/src/app/modules/vote/vote.module.ts @@ -9,18 +9,13 @@ import { VoteService } from './services/vote.service'; import { RouterModule } from '@angular/router'; import { routes } from './vote-routing'; import { HelpDialogueComponent } from './vote/help-dialogue/help-dialogue.component'; +import { ConversationModule } from '../conversation/conversation.module'; @NgModule({ declarations: [VoteComponent, StartVotingSessionComponent, VoteDialogueComponent, VoteSavedDialogueComponent, HelpDialogueComponent], - imports: [ - RouterModule.forChild(routes), - CommonModule, - AppMaterialModule, - ], + imports: [RouterModule.forChild(routes), CommonModule, AppMaterialModule, ConversationModule], entryComponents: [VoteDialogueComponent, VoteSavedDialogueComponent, HelpDialogueComponent], providers: [VoteService], - exports: [ - StartVotingSessionComponent - ] + exports: [StartVotingSessionComponent] }) -export class VoteModule { } +export class VoteModule {} diff --git a/src/app/modules/vote/vote/vote.component.html b/src/app/modules/vote/vote/vote.component.html index 216453e..b5f237b 100644 --- a/src/app/modules/vote/vote/vote.component.html +++ b/src/app/modules/vote/vote/vote.component.html @@ -27,7 +27,7 @@
+ [ngClass]="technology.quadrant.substr(0,9).toLowerCase()" (click)="technologySelected(technology)"> diff --git a/src/app/modules/vote/vote/vote.component.ts b/src/app/modules/vote/vote/vote.component.ts index 75c0df6..4fd49e6 100644 --- a/src/app/modules/vote/vote/vote.component.ts +++ b/src/app/modules/vote/vote/vote.component.ts @@ -127,6 +127,15 @@ export class VoteComponent implements AfterViewInit, OnDestroy { return this.votes.filter((v) => v.ring === ring); } + technologySelected(technology: Technology) { + const votingEventRound = this.voteService.credentials.votingEvent.round; + if (votingEventRound && votingEventRound > 1) { + this.goToConversation(technology); + } else { + this.openVoteDialog(technology); + } + } + createNewTechnology(name: string, quadrant: string) { const votingEvent = this.voteService.credentials.votingEvent; const technology: Technology = { @@ -170,6 +179,11 @@ export class VoteComponent implements AfterViewInit, OnDestroy { }); } + goToConversation(technology: Technology) { + this.voteService.technology = technology; + this.router.navigate(['vote/conversation']); + } + addVote(vote: Vote) { this.votes.push(vote); this.votes$.next(this.votes); diff --git a/src/app/services/backend.service.spec.ts b/src/app/services/backend.service.spec.ts index 5838172..02563aa 100644 --- a/src/app/services/backend.service.spec.ts +++ b/src/app/services/backend.service.spec.ts @@ -15,6 +15,7 @@ import { Vote } from '../models/vote'; import { JwtModule, JWT_OPTIONS } from '@auth0/angular-jwt'; import { apiDomain } from '../app.module'; import { logError } from '../utils/utils'; +import { Comment } from '../models/comment'; describe('BackendService', () => { let testToken; @@ -28,7 +29,7 @@ describe('BackendService', () => { return { tokenGetter: getTestToken, whitelistedDomains: apiDomain() - } + }; } const validUser = { user: 'abc', pwd: '123' }; @@ -353,83 +354,83 @@ describe('BackendService', () => { it(`5.2 tries to authenticate a user with the wrong password. It assumes that the users used in the test are correctly loaded in the backend`, (done) => { - const service: BackendService = TestBed.get(BackendService); - const invalidUser = { user: 'abc', pwd: '321' }; + const service: BackendService = TestBed.get(BackendService); + const invalidUser = { user: 'abc', pwd: '321' }; - service.authenticate(invalidUser.user, invalidUser.pwd).subscribe( - (resp) => { - logError('5.2 authenticate a user with wrong password ' + resp); - done(); - throw new Error('the authenticate logic has some issues'); - }, - (err) => { - expect(err.errorCode).toBe(ERRORS.pwdInvalid); - done(); - }, - () => done() - ); - }, 10000); + service.authenticate(invalidUser.user, invalidUser.pwd).subscribe( + (resp) => { + logError('5.2 authenticate a user with wrong password ' + resp); + done(); + throw new Error('the authenticate logic has some issues'); + }, + (err) => { + expect(err.errorCode).toBe(ERRORS.pwdInvalid); + done(); + }, + () => done() + ); + }, 10000); it(`5.3 tries to authenticate a user that does not exist. It assumes that the users used in the test are correctly loaded in the backend`, (done) => { - const service: BackendService = TestBed.get(BackendService); - const invalidUser = { user: 'not existing user', pwd: '321' }; + const service: BackendService = TestBed.get(BackendService); + const invalidUser = { user: 'not existing user', pwd: '321' }; - service.authenticate(invalidUser.user, invalidUser.pwd).subscribe( - (resp) => { - logError('5.3 authenticate a user that does not exist ' + resp); - done(); - throw new Error('the authenticate logic has some issues'); - }, - (err) => { - expect(err.errorCode).toBe(ERRORS.userUnknown); - done(); - }, - () => done() - ); - }, 10000); + service.authenticate(invalidUser.user, invalidUser.pwd).subscribe( + (resp) => { + logError('5.3 authenticate a user that does not exist ' + resp); + done(); + throw new Error('the authenticate logic has some issues'); + }, + (err) => { + expect(err.errorCode).toBe(ERRORS.userUnknown); + done(); + }, + () => done() + ); + }, 10000); }); describe('6 BackendService - getConfiguration', () => { it(`6.1 retrieve the configuration without specifying a user. It assumes that the configuration has been correctly loaded on the backend This can be achieved using the command node ./dist/src/mongodb/scripts/set-configuration-collection DEV`, (done) => { - const service: BackendService = TestBed.get(BackendService); + const service: BackendService = TestBed.get(BackendService); - service.getConfiguration().subscribe( - (configuration) => { - expect(configuration.revoteToggle).toBeFalsy(); - expect(configuration.secondValue).toBe('second'); - expect(configuration.thirdValue).toBeUndefined(); - }, - (err) => { - logError('6.1 retrieve the configuration without specifying a user ' + err); - done(); - throw new Error('the getConfiguration logic has some issues'); - }, - () => done() - ); - }, 10000); + service.getConfiguration().subscribe( + (configuration) => { + expect(configuration.revoteToggle).toBeFalsy(); + expect(configuration.secondValue).toBe('second'); + expect(configuration.thirdValue).toBeUndefined(); + }, + (err) => { + logError('6.1 retrieve the configuration without specifying a user ' + err); + done(); + throw new Error('the getConfiguration logic has some issues'); + }, + () => done() + ); + }, 10000); it(`6.2 retrieve the configuration specifying a user. It assumes that the configuration has been correctly loaded on the backend This can be achieved using the command node ./dist/src/mongodb/scripts/set-configuration-collection DEV`, (done) => { - const service: BackendService = TestBed.get(BackendService); + const service: BackendService = TestBed.get(BackendService); - service.getConfiguration('abc').subscribe( - (configuration) => { - expect(configuration.revoteToggle).toBeTruthy(); - expect(configuration.secondValue).toBe('second'); - expect(configuration.thirdValue).toBe('third'); - }, - (err) => { - logError('6.1 retrieve the configuration without specifying a user ' + err); - done(); - throw new Error('the getConfiguration logic has some issues'); - }, - () => done() - ); - }, 10000); + service.getConfiguration('abc').subscribe( + (configuration) => { + expect(configuration.revoteToggle).toBeTruthy(); + expect(configuration.secondValue).toBe('second'); + expect(configuration.thirdValue).toBe('third'); + }, + (err) => { + logError('6.1 retrieve the configuration without specifying a user ' + err); + done(); + throw new Error('the getConfiguration logic has some issues'); + }, + () => done() + ); + }, 10000); }); describe('7 BackendService - saveLogInfo', () => { @@ -641,14 +642,83 @@ describe('BackendService', () => { ) .subscribe({ error: (err) => { - logError('2.1 test the entire voting cycle ' + err); + console.error('10.1 test the entire voting cycle', err); done(); - throw new Error('the voting cycle does not work'); + throw new Error('add and retrieve comments do not work'); }, complete: () => done() }); }, 100000); }); + + it('10.2 add one vote with a comment, retrieve the vote and add a reply to the comment', (done) => { + const service: BackendService = TestBed.get(BackendService); + const votingEventName = 'a voting event with votes with comments and replies'; + const replyText = 'this is the REPLY to the comment'; + const replyAuthor = 'I am the author of the reply'; + let votes1: Vote[]; + let credentials1: VoteCredentials; + + let votingEvent; + + let tech1: Technology; + + service + .getVotingEvents({ all: true }) // first delete any votingEvent with the same name + .pipe( + map((votingEvents) => { + const vEvents = votingEvents.filter((ve) => ve.name === votingEventName); + return vEvents.map((ve) => service.cancelVotingEvent(ve._id, true)); + }), + concatMap((cancelVERequests) => (cancelVERequests.length > 0 ? forkJoin(cancelVERequests) : of(null))) + ) + .pipe( + concatMap(() => service.createVotingEvent(votingEventName)), + concatMap(() => service.getVotingEvents()), + tap((votingEvents) => { + const vEvents = votingEvents.filter((ve) => ve.name === votingEventName); + expect(vEvents.length).toBe(1); + credentials1 = { + voterId: { firstName: 'fReplied1', lastName: 'fReplied2' }, + votingEvent: null + }; + votingEvent = vEvents[0]; + credentials1.votingEvent = votingEvent; + }), + concatMap(() => service.openVotingEvent(votingEvent._id)), + concatMap(() => service.getVotingEvent(votingEvent._id)), + // the first voter, Commenter1, saves 1 vote with a comment + tap((vEvent) => { + tech1 = vEvent.technologies[0]; + votes1 = [{ ring: 'hold', technology: tech1, comment: { text: 'comment on the vote' } }]; + credentials1.votingEvent = vEvent; + }), + concatMap(() => service.saveVote(votes1, credentials1)), + concatMap(() => service.getVotesWithCommentsForTechAndEvent(tech1._id, votingEvent._id)), + concatMap((votes: Vote[]) => { + const theVote = votes[0]; + const theCommentId = theVote.comment.id; + const theReply: Comment = { text: replyText, author: replyAuthor }; + return service.addReplyToVoteComment(theVote._id, theReply, theCommentId); + }), + concatMap(() => service.getVotesWithCommentsForTechAndEvent(tech1._id, votingEvent._id)), + tap((votes: Vote[]) => { + const theVote = votes[0]; + expect(theVote.comment).toBeDefined(); + expect(theVote.comment.replies).toBeDefined(); + expect(theVote.comment.replies.length).toBe(1); + expect(theVote.comment.replies[0].text).toBe(replyText); + }) + ) + .subscribe({ + error: (err) => { + console.error('10.2 test the entire voting cycle', err); + done(); + throw new Error('add reply to a comment does not work'); + }, + complete: () => done() + }); + }, 100000); }); describe('redirect to radar page', () => { @@ -658,7 +728,7 @@ describe('redirect to radar page', () => { }) ); - it('should create proper query params for getting all blips', function () { + it('should create proper query params for getting all blips', function() { const service: BackendService = TestBed.get(BackendService); const windowOpenSpy = spyOn(window, 'open'); service.url = 'service-url/'; @@ -670,7 +740,7 @@ describe('redirect to radar page', () => { ); }); - it('should create title param with default sitename when site name not given in config', function () { + it('should create title param with default sitename when site name not given in config', function() { const service: BackendService = TestBed.get(BackendService); const windowOpenSpy = spyOn(window, 'open'); service.url = 'service-url/'; @@ -682,7 +752,7 @@ describe('redirect to radar page', () => { ); }); - it('should create proper query params for getting blips for event', function () { + it('should create proper query params for getting blips for event', function() { const service: BackendService = TestBed.get(BackendService); const windowOpenSpy = spyOn(window, 'open'); service.url = 'service-url/'; @@ -705,7 +775,7 @@ describe('redirect to radar page', () => { ); }); - it('should create proper query params for getting blips for revote', function () { + it('should create proper query params for getting blips for revote', function() { const service: BackendService = TestBed.get(BackendService); const windowOpenSpy = spyOn(window, 'open'); service.url = 'service-url'; diff --git a/src/app/services/backend.service.ts b/src/app/services/backend.service.ts index 3bbb775..2f2a82d 100644 --- a/src/app/services/backend.service.ts +++ b/src/app/services/backend.service.ts @@ -15,6 +15,7 @@ import { Blip } from '../models/blip'; import { ERRORS } from './errors'; import { logError } from '../utils/utils'; import { inspect } from 'util'; +import { Comment } from '../models/comment'; interface BackEndError { errorCode: string; @@ -152,6 +153,19 @@ export class BackendService { ); } + addReplyToVoteComment(voteId: string, reply: Comment, commentReceivingReplyId: string) { + const payload = this.buildPostPayloadForService(ServiceNames.addReplyToVoteComment); + payload['voteId'] = voteId; + payload['reply'] = reply; + payload['commentReceivingReplyId'] = commentReceivingReplyId; + return this.http.post(this.url, payload).pipe( + map((resp: any) => { + return resp.data; + }), + catchError(this.handleError) + ); + } + // getAggregatedVotes(votingEvent: VotingEvent): Observable> { // const payload = this.buildPostPayloadForService(ServiceNames.aggregateVotes); // payload['votingEvent'] = votingEvent; diff --git a/src/app/services/service-names.ts b/src/app/services/service-names.ts index b6bc224..fb237f9 100644 --- a/src/app/services/service-names.ts +++ b/src/app/services/service-names.ts @@ -9,6 +9,7 @@ export enum ServiceNames { saveVotes, aggregateVotes, getVotesWithCommentsForTechAndEvent, + addReplyToVoteComment, getVotingEvents, getVotingEvent, createVotingEvent, From 5c16503c1949f75dc41da237fd3d06ad52ea7e9e Mon Sep 17 00:00:00 2001 From: Enrico PIccinin Date: Sun, 30 Jun 2019 15:49:01 +0200 Subject: [PATCH 2/7] feat(voting-event-flow): guide flow via configuration initial implementation of the feature to guide the flow of the voting event via a configuration read from backend --- .talismanrc | 3 + src/app/app-routes.ts | 18 ++- src/app/app-session.service.ts | 35 +++++ src/app/app.component.spec.ts | 14 +- src/app/app.component.ts | 36 ++++- src/app/app.module.ts | 7 +- .../voting-event-select.component.html | 18 +++ .../voting-event-select.component.scss | 39 ++++++ .../voting-event-select.component.spec.ts | 28 ++++ .../voting-event-select.component.ts | 30 +++++ src/app/models/voting-event-flow.ts | 6 + src/app/models/voting-event-step.ts | 7 + src/app/models/voting-event.ts | 26 ++-- .../conversation/conversation.component.html | 2 +- .../conversation/conversation.component.ts | 16 +-- .../login-voting-event.component.html | 27 ++++ .../login-voting-event.component.scss | 52 ++++++++ .../login-voting-event.component.spec.ts | 25 ++++ .../login-voting-event.component.ts | 123 ++++++++++++++++++ src/app/modules/login/login.module.ts | 21 +-- src/app/modules/vote/vote/vote.component.ts | 19 ++- src/app/utils/voting-event-flow.util.ts | 46 +++++++ 22 files changed, 534 insertions(+), 64 deletions(-) create mode 100644 src/app/app-session.service.ts create mode 100644 src/app/components/voting-event-select/voting-event-select.component.html create mode 100644 src/app/components/voting-event-select/voting-event-select.component.scss create mode 100644 src/app/components/voting-event-select/voting-event-select.component.spec.ts create mode 100644 src/app/components/voting-event-select/voting-event-select.component.ts create mode 100644 src/app/models/voting-event-flow.ts create mode 100644 src/app/models/voting-event-step.ts create mode 100644 src/app/modules/login/login-voting-event/login-voting-event.component.html create mode 100644 src/app/modules/login/login-voting-event/login-voting-event.component.scss create mode 100644 src/app/modules/login/login-voting-event/login-voting-event.component.spec.ts create mode 100644 src/app/modules/login/login-voting-event/login-voting-event.component.ts create mode 100644 src/app/utils/voting-event-flow.util.ts diff --git a/.talismanrc b/.talismanrc index eeac8d3..bcdb5ae 100644 --- a/.talismanrc +++ b/.talismanrc @@ -33,4 +33,7 @@ fileignoreconfig: ignore_detectors: [] - filename: .make/cd/deploy_aws.sh checksum: ef91d1d1d9ba8571fbc8fbaa0459635a0bbef8bec2a8a2e1c9c6b21ace5b833c + ignore_detectors: [] +- filename: src/app/modules/login/login-voting-event/login-voting-event.component.ts + checksum: c56211684d1af6785d67fe6deda91af2b405037c80161090561eb388c642e5e7 ignore_detectors: [] \ No newline at end of file diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index c9cdfc9..e5986e5 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -1,6 +1,8 @@ import { Routes } from '@angular/router'; import { LoginComponent } from './modules/login/login.component'; import { ErrorComponent } from './components/error/error.component'; +import { VotingEventSelectComponent } from './components/voting-event-select/voting-event-select.component'; +import { LoginVotingEventComponent } from './modules/login/login-voting-event/login-voting-event.component'; export const appRoutes: Routes = [ { @@ -17,11 +19,19 @@ export const appRoutes: Routes = [ loadChildren: './modules/admin/admin.module#AdminModule' }, { - path: 'vote', - loadChildren: './modules/vote/vote.module#VoteModule' + path: 'selectVotingEvent', + component: VotingEventSelectComponent }, { - path: '**', - redirectTo: 'vote', + path: 'login-voting-event', + component: LoginVotingEventComponent }, + { + path: 'vote', + loadChildren: './modules/vote/vote.module#VoteModule' + } + // { + // path: '**', + // redirectTo: 'vote', + // }, ]; diff --git a/src/app/app-session.service.ts b/src/app/app-session.service.ts new file mode 100644 index 0000000..827442c --- /dev/null +++ b/src/app/app-session.service.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { VotingEvent } from 'src/app/models/voting-event'; +import { Technology } from './models/technology'; + +@Injectable({ + providedIn: 'root' +}) +export class AppSessionService { + private votingEvents: VotingEvent[]; + private selectedVotingEvent: VotingEvent; + private selectedTechnology: Technology; + + constructor() {} + + getVotingEvents() { + return this.votingEvents; + } + setVotingEvents(votingEvents: VotingEvent[]) { + this.votingEvents = votingEvents; + } + + getSelectedVotingEvent() { + return this.selectedVotingEvent; + } + setSelectedVotingEvent(votingEvent: VotingEvent) { + this.selectedVotingEvent = votingEvent; + } + + getSelectedTechnology() { + return this.selectedTechnology; + } + setSelectedTechnology(technology: Technology) { + this.selectedTechnology = technology; + } +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 8105902..8c14c53 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,5 +1,6 @@ import { TestBed, async } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; +import { HttpClientModule } from '@angular/common/http'; import { AppComponent } from './app.component'; import { HeaderComponent } from './components/header/header.component'; import { ConfigurationService } from './services/configuration.service'; @@ -8,18 +9,14 @@ describe('AppComponent', () => { beforeEach(async(() => { const configurationServiceSpy: jasmine.SpyObj = jasmine.createSpyObj('ConfigurationService', ['toString']); TestBed.configureTestingModule({ - imports: [ - RouterTestingModule - ], - declarations: [ - AppComponent, HeaderComponent - ], + imports: [RouterTestingModule, HttpClientModule], + declarations: [AppComponent, HeaderComponent], providers: [ { provide: ConfigurationService, useValue: configurationServiceSpy - }, - ], + } + ] }).compileComponents(); })); @@ -28,5 +25,4 @@ describe('AppComponent', () => { const app = fixture.debugElement.componentInstance; expect(app).toBeTruthy(); }); - }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index e37877c..6876b4a 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,16 +1,46 @@ -import { Component } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { version } from './version'; import { Router } from '@angular/router'; +import { BackendService } from './services/backend.service'; +import { ErrorService } from './services/error.service'; +import { AppSessionService } from './app-session.service'; +import { getIdentificationRoute } from './utils/voting-event-flow.util'; +import { map } from 'rxjs/operators'; @Component({ selector: 'byor-root', templateUrl: './app.component.html', styleUrls: ['./app.component.scss'] }) -export class AppComponent { +export class AppComponent implements OnInit { version = version; - constructor(private router: Router) {} + constructor( + private router: Router, + private backend: BackendService, + public errorService: ErrorService, + private appSession: AppSessionService + ) {} + + ngOnInit() { + this.backend + .getVotingEvents() + .pipe(map((votingEvents) => votingEvents.filter((ve) => ve.status === 'open'))) + .subscribe((votingEvents) => { + if (!votingEvents || votingEvents.length === 0) { + this.errorService.setError(new Error('There are no Voting Events open')); + this.router.navigate(['error']); + } else if (votingEvents.length === 1) { + const votingEvent = votingEvents[0]; + this.appSession.setSelectedVotingEvent(votingEvent); + const route = getIdentificationRoute(votingEvent); + this.router.navigate([route]); + } else { + this.appSession.setVotingEvents(votingEvents); + this.router.navigate(['selectVotingEvent']); + } + }); + } goToAdminPage() { this.router.navigate(['admin']); diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 726a6a8..bf969da 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -17,10 +17,7 @@ import { HttpErrorHandler } from './shared/http-error-handler/http-error-handler import { EventsService } from './services/events.service'; import { environment } from '../environments/environment'; import { getToken } from './utils/get-token'; - -// export function getToken() { -// return localStorage.getItem('access_token'); -// } +import { VotingEventSelectComponent } from './components/voting-event-select/voting-event-select.component'; export function apiDomain() { return [new URL(environment.serviceUrl).hostname + ':' + new URL(environment.serviceUrl).port]; @@ -34,7 +31,7 @@ export function jwtOptionsFactory() { } @NgModule({ - declarations: [AppComponent, ErrorComponent, HeaderComponent], + declarations: [AppComponent, ErrorComponent, HeaderComponent, VotingEventSelectComponent], imports: [ RouterModule.forRoot(appRoutes), BrowserModule, diff --git a/src/app/components/voting-event-select/voting-event-select.component.html b/src/app/components/voting-event-select/voting-event-select.component.html new file mode 100644 index 0000000..0eacf55 --- /dev/null +++ b/src/app/components/voting-event-select/voting-event-select.component.html @@ -0,0 +1,18 @@ +
+
+ + + + {{votingEvent.name}} + + + +
+
+ +
+ +
\ No newline at end of file diff --git a/src/app/components/voting-event-select/voting-event-select.component.scss b/src/app/components/voting-event-select/voting-event-select.component.scss new file mode 100644 index 0000000..0ea4b82 --- /dev/null +++ b/src/app/components/voting-event-select/voting-event-select.component.scss @@ -0,0 +1,39 @@ +@import "../../styles/abstracts/mixins"; + +$selection-sections-max-width: 480px; + +.selection-section { + margin: 1.3rem auto; + padding: 0 1.3rem; + max-width: $selection-sections-max-width; + + .event-selection { + display: block; + } + + mat-form-field { + width: 100%; + } + + .voter { + margin-bottom: 2rem; + + .voter-input { + width: 100%; + @include byor-input; + } + + label { + display: block; + margin-bottom: .3rem; + } + } + + .button-disabled { + border: none; + } + + .button-text { + color: #ffffff; + } +} \ No newline at end of file diff --git a/src/app/components/voting-event-select/voting-event-select.component.spec.ts b/src/app/components/voting-event-select/voting-event-select.component.spec.ts new file mode 100644 index 0000000..1e5a799 --- /dev/null +++ b/src/app/components/voting-event-select/voting-event-select.component.spec.ts @@ -0,0 +1,28 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { AppMaterialModule } from '../../app-material.module'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +import { VotingEventSelectComponent } from './voting-event-select.component'; + +describe('VotingEventSelectComponent', () => { + let component: VotingEventSelectComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [AppMaterialModule, RouterTestingModule, BrowserAnimationsModule], + declarations: [VotingEventSelectComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(VotingEventSelectComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/voting-event-select/voting-event-select.component.ts b/src/app/components/voting-event-select/voting-event-select.component.ts new file mode 100644 index 0000000..8490be0 --- /dev/null +++ b/src/app/components/voting-event-select/voting-event-select.component.ts @@ -0,0 +1,30 @@ +import { Component, OnInit } from '@angular/core'; +import { AppSessionService } from '../../app-session.service'; +import { VotingEvent } from 'src/app/models/voting-event'; +import { MatSelect } from '@angular/material/select'; +import { getIdentificationRoute } from 'src/app/utils/voting-event-flow.util'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'byor-voting-event-select', + templateUrl: './voting-event-select.component.html', + styleUrls: ['./voting-event-select.component.scss'] +}) +export class VotingEventSelectComponent implements OnInit { + votingEventName: string; + + constructor(public appSession: AppSessionService, private router: Router) {} + + ngOnInit() {} + + eventSelected(eventSelect: MatSelect) { + this.votingEventName = eventSelect.value; + } + + goToIdentification() { + const votingEvent = this.appSession.getVotingEvents().find((ve) => ve.name === this.votingEventName); + this.appSession.setSelectedVotingEvent(votingEvent); + const route = getIdentificationRoute(votingEvent); + this.router.navigate([route]); + } +} diff --git a/src/app/models/voting-event-flow.ts b/src/app/models/voting-event-flow.ts new file mode 100644 index 0000000..fd5f8ed --- /dev/null +++ b/src/app/models/voting-event-flow.ts @@ -0,0 +1,6 @@ +import { VotingEventStep } from './voting-event-step'; +import { VotingEvent } from './voting-event'; + +export interface VotingEventFlow { + steps: VotingEventStep[]; +} diff --git a/src/app/models/voting-event-step.ts b/src/app/models/voting-event-step.ts new file mode 100644 index 0000000..017e86b --- /dev/null +++ b/src/app/models/voting-event-step.ts @@ -0,0 +1,7 @@ +export type IdentificationTypeNames = 'nickname' | 'login'; +export type ActionNames = 'vote' | 'conversation' | 'recommendation'; + +export interface VotingEventStep { + identification: { name: IdentificationTypeNames; role?: any }; + action: { name: ActionNames; commentOnVoteBlocked?: boolean }; +} diff --git a/src/app/models/voting-event.ts b/src/app/models/voting-event.ts index a4171ab..ace0eaf 100644 --- a/src/app/models/voting-event.ts +++ b/src/app/models/voting-event.ts @@ -1,20 +1,22 @@ import { Technology } from './technology'; import { Blip } from './blip'; +import { VotingEventFlow } from './voting-event-flow'; -// @todo look for a better way to merge the need of specifying a union type and the need of +// @todo look for a better way to merge the need of specifying a union type and the need of // providing access to the possible values of the domain via easy to read property names export type VotingEventStatus = 'open' | 'closed'; export interface VotingEvent { - name: string; - status: VotingEventStatus; - creationTS: string; - _id: any; - lastOpenedTS?: string; - lastClosedTS?: string; - technologies?: Array; - blips?: Array; - round?: number; - openForRevote?: boolean; - hasTechnologiesForRevote?: boolean; + name: string; + status: VotingEventStatus; + creationTS: string; + _id: any; + lastOpenedTS?: string; + lastClosedTS?: string; + technologies?: Array; + blips?: Array; + round?: number; + openForRevote?: boolean; + hasTechnologiesForRevote?: boolean; + flow?: VotingEventFlow; } diff --git a/src/app/modules/conversation/conversation/conversation.component.html b/src/app/modules/conversation/conversation/conversation.component.html index 057b519..00460f9 100644 --- a/src/app/modules/conversation/conversation/conversation.component.html +++ b/src/app/modules/conversation/conversation/conversation.component.html @@ -1,4 +1,4 @@ -{{voteService.technology.name}} +{{appSession.getSelectedTechnology().name}}
diff --git a/src/app/modules/conversation/conversation/conversation.component.ts b/src/app/modules/conversation/conversation/conversation.component.ts index 3551394..4ca79de 100644 --- a/src/app/modules/conversation/conversation/conversation.component.ts +++ b/src/app/modules/conversation/conversation/conversation.component.ts @@ -9,6 +9,8 @@ import { VoteService } from '../../vote/services/vote.service'; import { map, switchMap, tap } from 'rxjs/operators'; import { Vote } from 'src/app/models/vote'; import { VoteCredentials } from 'src/app/models/vote-credentials'; +import { AppSessionService } from 'src/app/app-session.service'; +import { AuthService } from '../../login/auth.service'; /** Flat comment node with expandable and level information */ export class CommentFlatNode { @@ -51,13 +53,13 @@ export class ConversationComponent implements OnDestroy { showAddReplyButton = true; errorMessage: string; - constructor(private backEnd: BackendService, public voteService: VoteService) { + constructor(private backEnd: BackendService, private authService: AuthService, public appSession: AppSessionService) { this.treeFlattener = new MatTreeFlattener(this.transformer, this.getLevel, this.isExpandable, this.getChildren); this.treeControl = new FlatTreeControl(this.getLevel, this.isExpandable); this.dataSource = new MatTreeFlatDataSource(this.treeControl, this.treeFlattener); - const tech = this.voteService.technology; - const votingEvent = this.voteService.credentials.votingEvent; + const tech = this.appSession.getSelectedTechnology(); + const votingEvent = this.appSession.getSelectedVotingEvent(); this.commentRetrievalSubscription = this.triggerCommentRetrieval .pipe( switchMap((flatNode) => @@ -192,7 +194,7 @@ export class ConversationComponent implements OnDestroy { return null; } const nestedNode = this.flatNodeMap.get(node); - const author = this.getCommentAuthorName(this.voteService.credentials); + const author = this.authService.user; this.backEnd .addReplyToVoteComment(node.voteId, { text, author }, nestedNode.parentCommentId) .pipe( @@ -206,12 +208,6 @@ export class ConversationComponent implements OnDestroy { this.triggerCommentRetrieval.next(node); } - getCommentAuthorName(credentials: VoteCredentials) { - const firstName = credentials.voterId.firstName; - const lastName = credentials.voterId.lastName; - return `${firstName} ${lastName}`; - } - getTitle(node: CommentFlatNode) { let title = node.level > 0 ? `${node.author} - ${node.voteRing}` : node.author; if (node.level === 0) { diff --git a/src/app/modules/login/login-voting-event/login-voting-event.component.html b/src/app/modules/login/login-voting-event/login-voting-event.component.html new file mode 100644 index 0000000..43a29f2 --- /dev/null +++ b/src/app/modules/login/login-voting-event/login-voting-event.component.html @@ -0,0 +1,27 @@ +
+ +
+ + \ No newline at end of file diff --git a/src/app/modules/login/login-voting-event/login-voting-event.component.scss b/src/app/modules/login/login-voting-event/login-voting-event.component.scss new file mode 100644 index 0000000..97c04c3 --- /dev/null +++ b/src/app/modules/login/login-voting-event/login-voting-event.component.scss @@ -0,0 +1,52 @@ +@import "../../../styles/abstracts/mixins"; + +$login-sections-max-width: 480px; + +.login-section { + margin: 1.3rem auto; + padding: 0 1.3rem; + max-width: $login-sections-max-width; + + .event-selection { + display: block; + } + + .login { + margin-bottom: 2rem; + + .login-input { + width: 100%; + @include byor-input; + } + + label { + display: block; + margin-bottom: .3rem; + } + } + + .button-text { + color: #ffffff; + } + +} + + +.login-title { + background-color: white; + padding-top: 30px; + padding-bottom: 30px; + width: 100%; + text-align: center; + + h2 { + font-size: 48px; + line-height: 1em; + letter-spacing: -.06em; + font-weight: 400; + display: block; + padding: 0; + margin: 25px 0; + text-transform: uppercase; + } + } \ No newline at end of file diff --git a/src/app/modules/login/login-voting-event/login-voting-event.component.spec.ts b/src/app/modules/login/login-voting-event/login-voting-event.component.spec.ts new file mode 100644 index 0000000..4aae0ac --- /dev/null +++ b/src/app/modules/login/login-voting-event/login-voting-event.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LoginVotingEventComponent } from './login-voting-event.component'; + +describe('LoginVotingEventComponent', () => { + let component: LoginVotingEventComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ LoginVotingEventComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LoginVotingEventComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/modules/login/login-voting-event/login-voting-event.component.ts b/src/app/modules/login/login-voting-event/login-voting-event.component.ts new file mode 100644 index 0000000..7baaa85 --- /dev/null +++ b/src/app/modules/login/login-voting-event/login-voting-event.component.ts @@ -0,0 +1,123 @@ +import { Component, OnInit, ViewChild, ElementRef, AfterViewInit, OnDestroy } from '@angular/core'; +import { Observable, Subject, fromEvent, combineLatest, never, Subscription, merge, throwError } from 'rxjs'; +import { map, share, switchMap, catchError } from 'rxjs/operators'; +import { Router } from '@angular/router'; + +import { AuthService } from './../auth.service'; +import { ErrorService } from 'src/app/services/error.service'; +import { ERRORS } from 'src/app/services/errors'; +import { getToken } from '../../../utils/get-token'; +import { getActionRoute } from 'src/app/utils/voting-event-flow.util'; +import { AppSessionService } from 'src/app/app-session.service'; + +@Component({ + selector: 'byor-login-voting-event', + templateUrl: './login-voting-event.component.html', + styleUrls: ['./login-voting-event.component.scss'] +}) +export class LoginVotingEventComponent implements AfterViewInit, OnDestroy { + user$: Observable; + password$: Observable; + inputData$: Observable; + isValidInputData$: Observable; + clickOnLogin$: Observable<{ user: string; password: string }>; + message$ = new Subject(); + + loginSubscription: Subscription; + + @ViewChild('userid') userid: ElementRef; + @ViewChild('pwd') pwd: ElementRef; + @ViewChild('loginButton', { read: ElementRef }) loginButtonRef: ElementRef; + + constructor( + public authService: AuthService, + public router: Router, + public errorService: ErrorService, + public appSession: AppSessionService + ) {} + + ngAfterViewInit() { + const _user$ = fromEvent(this.userid.nativeElement, 'keyup').pipe(map(() => this.userid.nativeElement.value)); + const _password$ = fromEvent(this.pwd.nativeElement, 'keyup').pipe(map(() => this.pwd.nativeElement.value)); + const _loginButtonClick$ = fromEvent(this.loginButtonRef.nativeElement, 'click'); + + this.loginSubscription = this.logIn$(_user$, _password$, _loginButtonClick$).subscribe( + (authResp) => { + this.authService.isLoggedIn = authResp; + const redirect = getActionRoute(this.appSession.getSelectedVotingEvent()); + this.router.navigate([redirect]); + }, + (error) => { + this.errorService.setError(error); + this.router.navigate(['error']); + } + ); + } + ngOnDestroy() { + if (this.loginSubscription) { + this.loginSubscription.unsubscribe(); + } + } + + // this method takes in input all Observable created out of DOM events which are relevant for this Component + // in this way we can easily test this logic with marble tests + logIn$(userId$: Observable, pwd$: Observable, loginButtonClick$: Observable) { + this.user$ = merge( + // we need to create an Observable which emits the initial value of the inpuf field. + // This is because, in case of error, the `catchError` operator returns the source Observable and, at that point, + // this merge function would be re-executed and it would be important to emit the current content of the input field + new Observable((subscriber) => { + subscriber.next(this.userid.nativeElement.value); + subscriber.complete(); + }), + userId$ + ); + this.password$ = merge( + // we need to create an Observable which emits the initial value of the inpuf field. + // This is because, in case of error, the `catchError` operator returns the source Observable and, at that point, + // this merge function would be re-executed and it would be important to emit the current content of the input field + new Observable((subscriber) => { + subscriber.next(this.pwd.nativeElement.value); + subscriber.complete(); + }), + pwd$ + ); + + this.inputData$ = combineLatest(this.user$, this.password$); + this.isValidInputData$ = this.inputData$.pipe( + map(([user, password]) => this.isInputDataValid(user, password)), + share() // isValidInputData$ is subscribed in the template via asyc pipe - any other subscriber should use this subscription + ); + + this.clickOnLogin$ = combineLatest(this.isValidInputData$, this.inputData$).pipe( + switchMap(([isValid, [user, password]]) => { + if (isValid) { + return loginButtonClick$.pipe(map(() => ({ user, password }))); + } else { + return never(); + } + }) + ); + + let credentials; + return this.clickOnLogin$.pipe( + switchMap((_credentials) => { + credentials = _credentials; + return this.authService.login(credentials.user, credentials.password); + }), + catchError((err, caught) => { + if (err.errorCode === ERRORS.serverUnreacheable) { + return throwError(err.message); + } + if (err.message) { + this.message$.next(err.message); + return caught; + } + }) + ); + } + + isInputDataValid(user: string, password: string) { + return user.trim().length > 0 && password.trim().length > 0; + } +} diff --git a/src/app/modules/login/login.module.ts b/src/app/modules/login/login.module.ts index cf158b3..997d0d9 100644 --- a/src/app/modules/login/login.module.ts +++ b/src/app/modules/login/login.module.ts @@ -4,21 +4,12 @@ import { LoginComponent } from './login.component'; import { AuthGuard } from './auth.guard'; import { AuthService } from './auth.service'; import { AppMaterialModule } from '../../app-material.module'; +import { LoginVotingEventComponent } from './login-voting-event/login-voting-event.component'; @NgModule({ - declarations: [ - LoginComponent, - ], - providers: [ - AuthGuard, - AuthService, - ], - imports: [ - CommonModule, - AppMaterialModule, - ], - exports: [ - LoginComponent, - ] + declarations: [LoginComponent, LoginVotingEventComponent], + providers: [AuthGuard, AuthService], + imports: [CommonModule, AppMaterialModule], + exports: [LoginComponent] }) -export class LoginModule { } +export class LoginModule {} diff --git a/src/app/modules/vote/vote/vote.component.ts b/src/app/modules/vote/vote/vote.component.ts index 4fd49e6..bd8fab2 100644 --- a/src/app/modules/vote/vote/vote.component.ts +++ b/src/app/modules/vote/vote/vote.component.ts @@ -21,6 +21,8 @@ import * as _ from 'lodash'; import { TwRings } from 'src/app/models/ring'; import { Comment } from 'src/app/models/comment'; import { logError } from 'src/app/utils/utils'; +import { AppSessionService } from 'src/app/app-session.service'; +import { getActionName } from 'src/app/utils/voting-event-flow.util'; @Component({ selector: 'byor-vote', @@ -65,7 +67,8 @@ export class VoteComponent implements AfterViewInit, OnDestroy { private router: Router, private errorService: ErrorService, public dialog: MatDialog, - private voteService: VoteService + private voteService: VoteService, + private appSession: AppSessionService ) {} ngAfterViewInit() { @@ -109,7 +112,7 @@ export class VoteComponent implements AfterViewInit, OnDestroy { } getTechnologies() { - const votingEvent = this.voteService.credentials.votingEvent; + const votingEvent = this.appSession.getSelectedVotingEvent(); return this.backEnd.getVotingEvent(votingEvent._id).pipe( map((event) => { let technologies = event.technologies; @@ -128,11 +131,17 @@ export class VoteComponent implements AfterViewInit, OnDestroy { } technologySelected(technology: Technology) { - const votingEventRound = this.voteService.credentials.votingEvent.round; - if (votingEventRound && votingEventRound > 1) { + const votingEventRound = this.appSession.getSelectedVotingEvent().round; + this.appSession.setSelectedTechnology(technology); + const actionName = getActionName(this.appSession.getSelectedVotingEvent()); + if (actionName === 'vote') { + this.openVoteDialog(technology); + } else if (actionName === 'conversation') { + this.goToConversation(technology); + } else if (actionName === 'recommendation') { this.goToConversation(technology); } else { - this.openVoteDialog(technology); + throw new Error(`No route for action name "${actionName}"`); } } diff --git a/src/app/utils/voting-event-flow.util.ts b/src/app/utils/voting-event-flow.util.ts new file mode 100644 index 0000000..71ffcb4 --- /dev/null +++ b/src/app/utils/voting-event-flow.util.ts @@ -0,0 +1,46 @@ +import { VotingEvent } from '../models/voting-event'; + +export function getIdentificationType(votingEvent: VotingEvent) { + return getFlowStep(votingEvent).identification.name; +} + +export function getIdentificationRoute(votingEvent: VotingEvent) { + const identificationType = getIdentificationType(votingEvent); + let route: string; + if (identificationType === 'login') { + route = 'login-voting-event'; + } else if (identificationType === 'nickname') { + route = 'vote'; + } else { + throw new Error(`No route defined for identification type ${identificationType}`); + } + return route; +} + +export function getActionName(votingEvent: VotingEvent) { + return getFlowStep(votingEvent).action.name; +} + +export function getActionRoute(votingEvent: VotingEvent) { + const actionName = getActionName(votingEvent); + let route: string; + if (actionName === 'vote') { + route = 'vote'; + } else if (actionName === 'conversation') { + route = 'vote/start'; + } else { + throw new Error(`No route defined for action name "${actionName}"`); + } + return route; +} + +function getFlowStep(votingEvent: VotingEvent) { + if (!votingEvent.flow) { + throw new Error(`Voting Event ${votingEvent.name} does not have a flow defined`); + } + const round = votingEvent.round ? votingEvent.round : 1; + if (votingEvent.flow.steps.length < round) { + throw new Error(`Voting Event ${votingEvent.name} does not have a step defined in its flow for round ${round}`); + } + return votingEvent.flow.steps[round - 1]; +} From f7e28db04d34b445382673756bef8494b346ad0e Mon Sep 17 00:00:00 2001 From: Enrico PIccinin Date: Sun, 30 Jun 2019 19:06:01 +0200 Subject: [PATCH 3/7] feat(vot-dialogue-component): Comment on vote controlled by voting event flow The possibility to provide a comment while voting is controlled by the configuration of the VotingEventFlow --- .../voting-event-select.component.html | 2 +- .../voting-event-select.component.spec.ts | 17 ++++- .../conversation.component.spec.ts | 27 ++++++- .../login-voting-event.component.spec.ts | 13 +++- .../vote/vote/vote-dialogue.component.html | 2 +- .../vote/vote/vote-dialogue.component.spec.ts | 23 +++++- .../vote/vote/vote-dialogue.component.ts | 16 ++-- .../modules/vote/vote/vote.component.spec.ts | 73 +++++++++++++------ src/app/utils/voting-event-flow.util.ts | 4 + 9 files changed, 137 insertions(+), 40 deletions(-) diff --git a/src/app/components/voting-event-select/voting-event-select.component.html b/src/app/components/voting-event-select/voting-event-select.component.html index 0eacf55..19c1c7d 100644 --- a/src/app/components/voting-event-select/voting-event-select.component.html +++ b/src/app/components/voting-event-select/voting-event-select.component.html @@ -2,7 +2,7 @@
- + {{votingEvent.name}} diff --git a/src/app/components/voting-event-select/voting-event-select.component.spec.ts b/src/app/components/voting-event-select/voting-event-select.component.spec.ts index 1e5a799..c2567b6 100644 --- a/src/app/components/voting-event-select/voting-event-select.component.spec.ts +++ b/src/app/components/voting-event-select/voting-event-select.component.spec.ts @@ -4,6 +4,20 @@ import { RouterTestingModule } from '@angular/router/testing'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { VotingEventSelectComponent } from './voting-event-select.component'; +import { AppSessionService } from 'src/app/app-session.service'; +import { VotingEvent } from 'src/app/models/voting-event'; + +class MockAppSessionService { + private votingEvents: VotingEvent[]; + + constructor() { + this.votingEvents = [{ _id: '123', name: 'an event', status: 'open', creationTS: 'abc' }]; + } + + getVotingEvents() { + return this.votingEvents; + } +} describe('VotingEventSelectComponent', () => { let component: VotingEventSelectComponent; @@ -12,7 +26,8 @@ describe('VotingEventSelectComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [AppMaterialModule, RouterTestingModule, BrowserAnimationsModule], - declarations: [VotingEventSelectComponent] + declarations: [VotingEventSelectComponent], + providers: [{ provide: AppSessionService, useClass: MockAppSessionService }] }).compileComponents(); })); diff --git a/src/app/modules/conversation/conversation/conversation.component.spec.ts b/src/app/modules/conversation/conversation/conversation.component.spec.ts index 76e4377..d0d878d 100644 --- a/src/app/modules/conversation/conversation/conversation.component.spec.ts +++ b/src/app/modules/conversation/conversation/conversation.component.spec.ts @@ -11,6 +11,9 @@ import { observeOn, skip, take, map, tap } from 'rxjs/operators'; import { BackendService } from 'src/app/services/backend.service'; import { VoteService } from '../../vote/services/vote.service'; import { Vote } from 'src/app/models/vote'; +import { AppSessionService } from 'src/app/app-session.service'; +import { Technology } from 'src/app/models/technology'; +import { VotingEvent } from 'src/app/models/voting-event'; const TEST_TECHNOLOGIES = [ { @@ -58,6 +61,24 @@ const TEST_TECHNOLOGY = { description: 'Description of Babel' }; +class MockAppSessionService { + private selectedTechnology: Technology; + private selectedVotingEvent: VotingEvent; + + constructor() { + this.selectedTechnology = TEST_TECHNOLOGY; + this.selectedVotingEvent = { _id: '123', name: 'an event', status: 'open', creationTS: 'abc' }; + } + + getSelectedTechnology() { + return this.selectedTechnology; + } + + getSelectedVotingEvent() { + return this.selectedVotingEvent; + } +} + const firsCommentId = 'firsCommentId'; const replyToFirsCommentId = 'replyToFirsCommentId'; const secondReplyToReplyToFirsCommentId = 'replyToReplyToFirsCommentId'; @@ -120,7 +141,11 @@ describe('ConversationComponent', () => { TestBed.configureTestingModule({ declarations: [ConversationComponent, CommentCardComponent], imports: [AppMaterialModule, MatTreeModule, HttpClientTestingModule], - providers: [{ provide: BackendService, useClass: MockBackEndService }, { provide: VoteService, useClass: MockVoteService }] + providers: [ + { provide: BackendService, useClass: MockBackEndService }, + { provide: VoteService, useClass: MockVoteService }, + { provide: AppSessionService, useClass: MockAppSessionService } + ] }).compileComponents(); })); diff --git a/src/app/modules/login/login-voting-event/login-voting-event.component.spec.ts b/src/app/modules/login/login-voting-event/login-voting-event.component.spec.ts index 4aae0ac..e7eac64 100644 --- a/src/app/modules/login/login-voting-event/login-voting-event.component.spec.ts +++ b/src/app/modules/login/login-voting-event/login-voting-event.component.spec.ts @@ -1,6 +1,12 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; + +import { AppMaterialModule } from '../../../app-material.module'; import { LoginVotingEventComponent } from './login-voting-event.component'; +import { AppSessionService } from 'src/app/app-session.service'; describe('LoginVotingEventComponent', () => { let component: LoginVotingEventComponent; @@ -8,9 +14,10 @@ describe('LoginVotingEventComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - declarations: [ LoginVotingEventComponent ] - }) - .compileComponents(); + declarations: [LoginVotingEventComponent], + imports: [BrowserAnimationsModule, HttpClientTestingModule, RouterTestingModule, AppMaterialModule], + providers: [AppSessionService] + }).compileComponents(); })); beforeEach(() => { diff --git a/src/app/modules/vote/vote/vote-dialogue.component.html b/src/app/modules/vote/vote/vote-dialogue.component.html index 317c2bd..9af46e3 100644 --- a/src/app/modules/vote/vote/vote-dialogue.component.html +++ b/src/app/modules/vote/vote/vote-dialogue.component.html @@ -17,7 +17,7 @@ - + Comment diff --git a/src/app/modules/vote/vote/vote-dialogue.component.spec.ts b/src/app/modules/vote/vote/vote-dialogue.component.spec.ts index fb07828..09f40bf 100644 --- a/src/app/modules/vote/vote/vote-dialogue.component.spec.ts +++ b/src/app/modules/vote/vote/vote-dialogue.component.spec.ts @@ -14,6 +14,26 @@ import { } from '@angular/material'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { VotingEvent } from 'src/app/models/voting-event'; +import { AppSessionService } from 'src/app/app-session.service'; + +class MockAppSessionService { + private selectedVotingEvent: VotingEvent; + + constructor() { + this.selectedVotingEvent = { + _id: '123', + name: 'an event', + status: 'open', + creationTS: 'abc', + flow: { steps: [{ identification: { name: 'nickname' }, action: { name: 'vote' } }] } + }; + } + + getSelectedVotingEvent() { + return this.selectedVotingEvent; + } +} describe('VoteDialogueComponent', () => { let component: VoteDialogueComponent; @@ -38,7 +58,8 @@ describe('VoteDialogueComponent', () => { providers: [ { provide: MatDialog, useValue: {} }, { provide: MatDialogRef, useValue: {} }, - { provide: MAT_DIALOG_DATA, useValue: matDialogData } + { provide: MAT_DIALOG_DATA, useValue: matDialogData }, + { provide: AppSessionService, useClass: MockAppSessionService } ] }).compileComponents(); })); diff --git a/src/app/modules/vote/vote/vote-dialogue.component.ts b/src/app/modules/vote/vote/vote-dialogue.component.ts index 8ae4bfb..0e777c8 100644 --- a/src/app/modules/vote/vote/vote-dialogue.component.ts +++ b/src/app/modules/vote/vote/vote-dialogue.component.ts @@ -3,9 +3,9 @@ import { MatDialogRef, MAT_DIALOG_DATA, MatDialog } from '@angular/material'; import { TwRings } from '../../../models/ring'; import { HelpDialogueComponent } from './help-dialogue/help-dialogue.component'; -import { ConfigurationService } from 'src/app/services/configuration.service'; -import { shareReplay, map } from 'rxjs/operators'; import { Observable } from 'rxjs'; +import { AppSessionService } from 'src/app/app-session.service'; +import { getAction } from 'src/app/utils/voting-event-flow.util'; @Component({ selector: 'byor-vote-dialogue', @@ -22,19 +22,19 @@ export class VoteDialogueComponent implements OnInit { public dialog: MatDialog, public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: any, - private configurationService: ConfigurationService + private appSession: AppSessionService ) {} - ngOnInit() { - this.configuration$ = this.configurationService.defaultConfiguration().pipe(shareReplay(1)); - } + ngOnInit() {} allowVote() { return this.data.message ? false : true; } - showComment$() { - return this.configuration$.pipe(map((config) => (this.allowVote() ? !config.hideVoteComment : false))); + showComment() { + const votingEvent = this.appSession.getSelectedVotingEvent(); + const actionStep = getAction(votingEvent); + return !actionStep.commentOnVoteBlocked; } showHelp() { diff --git a/src/app/modules/vote/vote/vote.component.spec.ts b/src/app/modules/vote/vote/vote.component.spec.ts index bf8d08b..50e4f33 100644 --- a/src/app/modules/vote/vote/vote.component.spec.ts +++ b/src/app/modules/vote/vote/vote.component.spec.ts @@ -1,5 +1,5 @@ import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, } from '@angular/common/http/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { of, asyncScheduler } from 'rxjs'; @@ -11,28 +11,45 @@ import { VoteService } from '../services/vote.service'; import { VoteComponent } from './vote.component'; import { BackendService } from '../../../services/backend.service'; import { TwRings } from 'src/app/models/ring'; +import { AppSessionService } from 'src/app/app-session.service'; +import { VotingEvent } from 'src/app/models/voting-event'; const TEST_TECHNOLOGIES = [ { - id: '0001', name: 'Babel', quadrant: 'Tools', isnew: true, + id: '0001', + name: 'Babel', + quadrant: 'Tools', + isnew: true, description: 'Description of Babel' }, { - id: '0002', name: 'Ember.js', quadrant: 'Languages & Frameworks', isnew: true, + id: '0002', + name: 'Ember.js', + quadrant: 'Languages & Frameworks', + isnew: true, description: 'Description of Ember.js' }, { - id: '0003', name: 'Docker', quadrant: 'Platforms', isnew: false, + id: '0003', + name: 'Docker', + quadrant: 'Platforms', + isnew: false, description: 'Description of Docker' }, { - id: '0004', name: 'Consumer-driven contract testing', quadrant: 'Techniques', isnew: true, + id: '0004', + name: 'Consumer-driven contract testing', + quadrant: 'Techniques', + isnew: true, description: 'Description of Consumer-driven contract testin' }, { - id: '0005', name: 'LambdaCD', quadrant: 'Tools', isnew: true, + id: '0005', + name: 'LambdaCD', + quadrant: 'Tools', + isnew: true, description: 'Description of LambdaCD' - }, + } ]; class MockBackEndService { getVotingEvent() { @@ -50,6 +67,17 @@ class MockVoteService { }; } } +class MockAppSessionService { + private selectedVotingEvent: VotingEvent; + + constructor() { + this.selectedVotingEvent = { _id: '123', name: 'an event', status: 'open', creationTS: 'abc' }; + } + + getSelectedVotingEvent() { + return this.selectedVotingEvent; + } +} describe('VoteComponent', () => { let component: VoteComponent; @@ -58,13 +86,13 @@ describe('VoteComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [VoteComponent], - imports: [BrowserAnimationsModule, HttpClientTestingModule, RouterTestingModule, AppMaterialModule,], + imports: [BrowserAnimationsModule, HttpClientTestingModule, RouterTestingModule, AppMaterialModule], providers: [ { provide: BackendService, useClass: MockBackEndService }, { provide: VoteService, useClass: MockVoteService }, + { provide: AppSessionService, useClass: MockAppSessionService } ] - }) - .compileComponents(); + }).compileComponents(); })); beforeEach(() => { @@ -89,7 +117,7 @@ describe('VoteComponent', () => { component.quadrantSelected(quadrantSelected); fixture.whenStable().then(() => { - expect(component.technologiesToShow.length).toBe(TEST_TECHNOLOGIES.filter(d => d.quadrant === quadrantSelected).length); + expect(component.technologiesToShow.length).toBe(TEST_TECHNOLOGIES.filter((d) => d.quadrant === quadrantSelected).length); }); }); @@ -114,7 +142,7 @@ describe('VoteComponent', () => { component.addVote(vote); fixture.whenStable().then(() => { - expect(component.technologiesToShow.find(t => t.name === vote.technology.name)).toBeUndefined(); + expect(component.technologiesToShow.find((t) => t.name === vote.technology.name)).toBeUndefined(); }); }); @@ -130,7 +158,7 @@ describe('VoteComponent', () => { component.removeVote(vote); fixture.whenStable().then(() => { - expect(component.technologiesToShow.find(t => t.name === vote.technology.name)).toBeDefined(); + expect(component.technologiesToShow.find((t) => t.name === vote.technology.name)).toBeDefined(); }); }); @@ -159,14 +187,13 @@ describe('VoteComponent', () => { expect(component.getVotesByRing(TwRings.names[0])).toEqual([firstVote]); expect(component.getVotesByRing(TwRings.names[1])).toEqual([secondVote, thirdVote]); expect(component.getVotesByRing(TwRings.names[2]).length).toEqual(0); - }); describe('search for technology', () => { it('should list down all the technologies that matches the search string', () => { - component.search$ = of(TEST_TECHNOLOGIES[0].name) + component.search$ = of(TEST_TECHNOLOGIES[0].name); fixture.whenStable().then(() => { - expect(component.technologiesToShow.find(t => t === TEST_TECHNOLOGIES[0])); + expect(component.technologiesToShow.find((t) => t === TEST_TECHNOLOGIES[0])); }); }); @@ -186,10 +213,10 @@ describe('VoteComponent', () => { expect(component.technologiesToShow.length).toBe(0); }); }); - }) + }); describe('vote exist', () => { - it('should return true when vote is already added', function () { + it('should return true when vote is already added', function() { const ring = 'hold'; const technologyVotedIndex = 1; const vote = { @@ -200,11 +227,10 @@ describe('VoteComponent', () => { component.addVote(vote); const isExist = component.isAlreadyVoted(TEST_TECHNOLOGIES[technologyVotedIndex].name); - expect(isExist).toBeTruthy() - + expect(isExist).toBeTruthy(); }); - it('should return false when vote does not find in the voted list', function () { + it('should return false when vote does not find in the voted list', function() { const ring = 'hold'; const technologyVotedIndex = 1; const vote = { @@ -213,8 +239,7 @@ describe('VoteComponent', () => { }; component.addVote(vote); const isExist = component.isAlreadyVoted('random'); - expect(isExist).toBeFalsy() - + expect(isExist).toBeFalsy(); }); - }) + }); }); diff --git a/src/app/utils/voting-event-flow.util.ts b/src/app/utils/voting-event-flow.util.ts index 71ffcb4..3801319 100644 --- a/src/app/utils/voting-event-flow.util.ts +++ b/src/app/utils/voting-event-flow.util.ts @@ -17,6 +17,10 @@ export function getIdentificationRoute(votingEvent: VotingEvent) { return route; } +export function getAction(votingEvent: VotingEvent) { + return getFlowStep(votingEvent).action; +} + export function getActionName(votingEvent: VotingEvent) { return getFlowStep(votingEvent).action.name; } From 40e01c777eadf5a6e8c226960b69ccf4bd1fe84f Mon Sep 17 00:00:00 2001 From: Enrico PIccinin Date: Sun, 30 Jun 2019 19:25:24 +0200 Subject: [PATCH 4/7] fix(voting-event-select.component): votingEvents private property removed from html template votingEvents private property removed from html template and substituted with getVotingEvents() to allow build for production --- .../voting-event-select/voting-event-select.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/voting-event-select/voting-event-select.component.html b/src/app/components/voting-event-select/voting-event-select.component.html index 19c1c7d..c56f12b 100644 --- a/src/app/components/voting-event-select/voting-event-select.component.html +++ b/src/app/components/voting-event-select/voting-event-select.component.html @@ -2,7 +2,7 @@
- + {{votingEvent.name}} From e7ce94da5e96a31a586b1dee86453f155e0db4f3 Mon Sep 17 00:00:00 2001 From: Enrico PIccinin Date: Mon, 1 Jul 2019 20:49:39 +0200 Subject: [PATCH 5/7] feat(loginForVotingEvent): loginForVotingEvent api call added with loginForVotingEvent it is possible to call the back end api that checks a user that wants to login to participate to steps of a VotingEvent which are protected by authentication --- .talismanrc | 2 +- src/app/models/technology.ts | 3 + src/app/modules/login/auth.service.ts | 10 + src/app/services/backend.service.spec.ts | 439 +++++++++++++++-------- src/app/services/backend.service.ts | 62 ++++ src/app/services/service-names.ts | 5 + 6 files changed, 371 insertions(+), 150 deletions(-) diff --git a/.talismanrc b/.talismanrc index bcdb5ae..2a7da69 100644 --- a/.talismanrc +++ b/.talismanrc @@ -17,7 +17,7 @@ fileignoreconfig: checksum: 510ff54745a0315fdaa5de0cf6923cc4e30081789e358120baf277f8cd1b5379 ignore_detectors: [] - filename: src/app/services/backend.service.spec.ts - checksum: 46591e64a9ade919c17223c375f76c56b117c3ada0ce53362970165811fb1631 + checksum: 4fb42ea12edc56c266d61835bd6ffd096c261441554f4c25b37864031299915a ignore_detectors: [] - filename: docs/images/vote_process.gif checksum: c3a82314b7db3029e5dd9b4226bde884f472561112b4287b5193eeeab1a7cf75 diff --git a/src/app/models/technology.ts b/src/app/models/technology.ts index d20118a..83993e6 100644 --- a/src/app/models/technology.ts +++ b/src/app/models/technology.ts @@ -1,3 +1,5 @@ +import { Comment } from './comment'; + export interface Technology { _id?: string; id?: string; @@ -7,4 +9,5 @@ export interface Technology { description: string; imageFile?: string; forRevote?: boolean; + comments?: Comment[]; } diff --git a/src/app/modules/login/auth.service.ts b/src/app/modules/login/auth.service.ts index 362f7fb..d73e22f 100644 --- a/src/app/modules/login/auth.service.ts +++ b/src/app/modules/login/auth.service.ts @@ -31,4 +31,14 @@ export class AuthService { this.isLoggedIn = false; localStorage.removeItem('access_token'); } + + loginForVotingEvent(user: string, pwd: string, role: string, votingEventId: string) { + return this.backend.authenticateForVotingEvent(user, pwd, role, votingEventId).pipe( + tap(({ token, pwdInserted }) => { + if (token) { + localStorage.setItem('access_token', token); + } + }) + ); + } } diff --git a/src/app/services/backend.service.spec.ts b/src/app/services/backend.service.spec.ts index 02563aa..6abfac7 100644 --- a/src/app/services/backend.service.spec.ts +++ b/src/app/services/backend.service.spec.ts @@ -172,7 +172,6 @@ describe('BackendService', () => { .subscribe({ error: (err) => { logError('2.1 test the entire voting cycle ' + err); - done(); throw new Error('the voting cycle does not work'); }, complete: () => done() @@ -180,77 +179,77 @@ describe('BackendService', () => { }, 100000); }); - describe('3 BackendService - get aggregated votes', () => { - // it('3.1 test the vote aggregation logic', done => { - // const service: BackendService = TestBed.get(BackendService); - // const votingEventName = 'theAggregationVotingEvent'; - // // there are 3 voters casting votes on 2 technologies, tech1 and tech2 - // // tech1 has 2 adopt and 1 hold - // // tech2 has 1 hold and 2 assess - // const techName1 = 'tech1'; - // const techName2 = 'tech2'; - // const votes1 = [ - // {ring: 'adopt', technology: {id: '1', name: techName1, description: 'desc1', quadrant: 'tools', isnew: true}}, - // {ring: 'hold', technology: {id: '2', name: techName2, description: 'desc2', quadrant: 'platforms', isnew: false}} - // ]; - // const credentials1: VoteCredentials = { - // voterId: {firstName: 'fv1', lastName: 'lv1'}, - // votingEvent: null - // }; - // const votes2 = [ - // {ring: 'adopt', technology: {id: '1', name: techName1, description: 'desc1', quadrant: 'tools', isnew: true}}, - // {ring: 'assess', technology: {id: '2', name: techName2, description: 'desc2', quadrant: 'platforms', isnew: false}} - // ]; - // const credentials2: VoteCredentials = { - // voterId: {firstName: 'fv2', lastName: 'lv2'}, - // votingEvent: null - // }; - // const votes3 = [ - // {ring: 'hold', technology: {id: '1', name: techName1, description: 'desc1', quadrant: 'tools', isnew: true}}, - // {ring: 'assess', technology: {id: '2', name: techName2, description: 'desc2', quadrant: 'platforms', isnew: false}} - // ]; - // const credentials3: VoteCredentials = { - // voterId: {firstName: 'fv3', lastName: 'lv3'}, - // votingEvent: null - // }; - // let votingEvent; - // service.cancelVotingEvent(votingEventName, true) - // .pipe( - // switchMap(() => service.createVotingEvent(votingEventName)), - // switchMap(() => service.getVotingEvents()), - // tap(votingEvents => { - // votingEvent = votingEvents.find(ve => ve.name === votingEventName); - // credentials1.votingEvent = votingEvent; - // credentials2.votingEvent = votingEvent; - // credentials3.votingEvent = votingEvent; - // }), - // switchMap(() => service.openVotingEvent(votingEvent._id)), - // switchMap(() => service.saveVote(votes1, credentials1)), - // switchMap(() => service.saveVote(votes2, credentials2)), - // switchMap(() => service.saveVote(votes3, credentials3)), - // switchMap(() => service.getAggregatedVotes(votingEvent)), - // ) - // .subscribe( - // aggregatesVotes => { - // expect(aggregatesVotes.length).toBe(4); - // const aggVotesTech1 = aggregatesVotes.filter(av => av.technology.name === techName1); - // expect(aggVotesTech1.length).toBe(2); - // expect(aggVotesTech1.find(av => av.count === 2).ring).toBe('adopt'); - // expect(aggVotesTech1.find(av => av.count === 1).ring).toBe('hold'); - // const aggVotesTech2 = aggregatesVotes.filter(av => av.technology.name === techName2); - // expect(aggVotesTech2.length).toBe(2); - // expect(aggVotesTech2.find(av => av.count === 2).ring).toBe('assess'); - // expect(aggVotesTech2.find(av => av.count === 1).ring).toBe('hold'); - // }, - // err => { - // logError('3.1 test the vote aggregation logic', err); - // done(); - // throw(new Error('the vote aggregation logic has some issues')); - // }, - // () => done() - // ); - // }, 20000); - }); + // describe('3 BackendService - get aggregated votes', () => { + // it('3.1 test the vote aggregation logic', done => { + // const service: BackendService = TestBed.get(BackendService); + // const votingEventName = 'theAggregationVotingEvent'; + // // there are 3 voters casting votes on 2 technologies, tech1 and tech2 + // // tech1 has 2 adopt and 1 hold + // // tech2 has 1 hold and 2 assess + // const techName1 = 'tech1'; + // const techName2 = 'tech2'; + // const votes1 = [ + // {ring: 'adopt', technology: {id: '1', name: techName1, description: 'desc1', quadrant: 'tools', isnew: true}}, + // {ring: 'hold', technology: {id: '2', name: techName2, description: 'desc2', quadrant: 'platforms', isnew: false}} + // ]; + // const credentials1: VoteCredentials = { + // voterId: {firstName: 'fv1', lastName: 'lv1'}, + // votingEvent: null + // }; + // const votes2 = [ + // {ring: 'adopt', technology: {id: '1', name: techName1, description: 'desc1', quadrant: 'tools', isnew: true}}, + // {ring: 'assess', technology: {id: '2', name: techName2, description: 'desc2', quadrant: 'platforms', isnew: false}} + // ]; + // const credentials2: VoteCredentials = { + // voterId: {firstName: 'fv2', lastName: 'lv2'}, + // votingEvent: null + // }; + // const votes3 = [ + // {ring: 'hold', technology: {id: '1', name: techName1, description: 'desc1', quadrant: 'tools', isnew: true}}, + // {ring: 'assess', technology: {id: '2', name: techName2, description: 'desc2', quadrant: 'platforms', isnew: false}} + // ]; + // const credentials3: VoteCredentials = { + // voterId: {firstName: 'fv3', lastName: 'lv3'}, + // votingEvent: null + // }; + // let votingEvent; + // service.cancelVotingEvent(votingEventName, true) + // .pipe( + // switchMap(() => service.createVotingEvent(votingEventName)), + // switchMap(() => service.getVotingEvents()), + // tap(votingEvents => { + // votingEvent = votingEvents.find(ve => ve.name === votingEventName); + // credentials1.votingEvent = votingEvent; + // credentials2.votingEvent = votingEvent; + // credentials3.votingEvent = votingEvent; + // }), + // switchMap(() => service.openVotingEvent(votingEvent._id)), + // switchMap(() => service.saveVote(votes1, credentials1)), + // switchMap(() => service.saveVote(votes2, credentials2)), + // switchMap(() => service.saveVote(votes3, credentials3)), + // switchMap(() => service.getAggregatedVotes(votingEvent)), + // ) + // .subscribe( + // aggregatesVotes => { + // expect(aggregatesVotes.length).toBe(4); + // const aggVotesTech1 = aggregatesVotes.filter(av => av.technology.name === techName1); + // expect(aggVotesTech1.length).toBe(2); + // expect(aggVotesTech1.find(av => av.count === 2).ring).toBe('adopt'); + // expect(aggVotesTech1.find(av => av.count === 1).ring).toBe('hold'); + // const aggVotesTech2 = aggregatesVotes.filter(av => av.technology.name === techName2); + // expect(aggVotesTech2.length).toBe(2); + // expect(aggVotesTech2.find(av => av.count === 2).ring).toBe('assess'); + // expect(aggVotesTech2.find(av => av.count === 1).ring).toBe('hold'); + // }, + // err => { + // logError('3.1 test the vote aggregation logic', err); + // done(); + // throw(new Error('the vote aggregation logic has some issues')); + // }, + // () => done() + // ); + // }, 20000); + // }); // describe('4 BackendService - calculate blips', () => { // let httpClient: HttpClient; @@ -345,7 +344,6 @@ describe('BackendService', () => { }, (err) => { logError('5.1 authenticate a valid user ' + err); - done(); throw new Error('the authenticate logic has some issues'); }, () => done() @@ -360,7 +358,6 @@ describe('BackendService', () => { service.authenticate(invalidUser.user, invalidUser.pwd).subscribe( (resp) => { logError('5.2 authenticate a user with wrong password ' + resp); - done(); throw new Error('the authenticate logic has some issues'); }, (err) => { @@ -379,7 +376,6 @@ describe('BackendService', () => { service.authenticate(invalidUser.user, invalidUser.pwd).subscribe( (resp) => { logError('5.3 authenticate a user that does not exist ' + resp); - done(); throw new Error('the authenticate logic has some issues'); }, (err) => { @@ -405,7 +401,6 @@ describe('BackendService', () => { }, (err) => { logError('6.1 retrieve the configuration without specifying a user ' + err); - done(); throw new Error('the getConfiguration logic has some issues'); }, () => done() @@ -425,7 +420,6 @@ describe('BackendService', () => { }, (err) => { logError('6.1 retrieve the configuration without specifying a user ' + err); - done(); throw new Error('the getConfiguration logic has some issues'); }, () => done() @@ -446,7 +440,6 @@ describe('BackendService', () => { }, (err) => { logError('7.1 saveLogInfo should not raise an error ' + err); - done(); throw new Error('the saveLogInfo logic has some issues'); }, () => done() @@ -504,7 +497,6 @@ describe('BackendService', () => { .subscribe({ error: (err) => { logError('8.1 addTechnology should not raise an error ' + err); - done(); throw new Error('the addTechnology logic has some issues'); }, complete: () => done() @@ -553,7 +545,6 @@ describe('BackendService', () => { .subscribe({ error: (err) => { logError('9.1 test adding technology to an event ' + err); - done(); throw new Error('adding technology to an event does not work'); }, complete: () => done() @@ -643,82 +634,232 @@ describe('BackendService', () => { .subscribe({ error: (err) => { console.error('10.1 test the entire voting cycle', err); - done(); throw new Error('add and retrieve comments do not work'); }, complete: () => done() }); }, 100000); + it('10.2 add one vote with a comment, retrieve the vote and add a reply to the comment', (done) => { + const service: BackendService = TestBed.get(BackendService); + const votingEventName = 'a voting event with votes with comments and replies'; + const replyText = 'this is the REPLY to the comment'; + const replyAuthor = 'I am the author of the reply'; + let votes1: Vote[]; + let credentials1: VoteCredentials; + + let votingEvent; + + let tech1: Technology; + + service + .authenticate(validUser.user, validUser.pwd) + .pipe( + concatMap(() => service.getVotingEvents({ all: true })), // first delete any votingEvent with the same name + map((votingEvents) => { + const vEvents = votingEvents.filter((ve) => ve.name === votingEventName); + return vEvents.map((ve) => service.cancelVotingEvent(ve._id, true)); + }), + concatMap((cancelVERequests) => (cancelVERequests.length > 0 ? forkJoin(cancelVERequests) : of(null))) + ) + .pipe( + concatMap(() => service.createVotingEvent(votingEventName)), + concatMap(() => service.getVotingEvents()), + tap((votingEvents) => { + const vEvents = votingEvents.filter((ve) => ve.name === votingEventName); + expect(vEvents.length).toBe(1); + credentials1 = { + voterId: { firstName: 'fReplied1', lastName: 'fReplied2' }, + votingEvent: null + }; + votingEvent = vEvents[0]; + credentials1.votingEvent = votingEvent; + }), + concatMap(() => service.openVotingEvent(votingEvent._id)), + concatMap(() => service.getVotingEvent(votingEvent._id)), + // the first voter, Commenter1, saves 1 vote with a comment + tap((vEvent) => { + tech1 = vEvent.technologies[0]; + votes1 = [{ ring: 'hold', technology: tech1, comment: { text: 'comment on the vote' } }]; + credentials1.votingEvent = vEvent; + }), + concatMap(() => service.saveVote(votes1, credentials1)), + concatMap(() => service.getVotesWithCommentsForTechAndEvent(tech1._id, votingEvent._id)), + concatMap((votes: Vote[]) => { + const theVote = votes[0]; + const theCommentId = theVote.comment.id; + const theReply: Comment = { text: replyText, author: replyAuthor }; + return service.addReplyToVoteComment(theVote._id, theReply, theCommentId); + }), + concatMap(() => service.getVotesWithCommentsForTechAndEvent(tech1._id, votingEvent._id)), + tap((votes: Vote[]) => { + const theVote = votes[0]; + expect(theVote.comment).toBeDefined(); + expect(theVote.comment.replies).toBeDefined(); + expect(theVote.comment.replies.length).toBe(1); + expect(theVote.comment.replies[0].text).toBe(replyText); + }) + ) + .subscribe({ + error: (err) => { + console.error('10.2 test the entire voting cycle', err); + throw new Error('add reply to a comment does not work'); + }, + complete: () => done() + }); + }, 100000); }); - it('10.2 add one vote with a comment, retrieve the vote and add a reply to the comment', (done) => { - const service: BackendService = TestBed.get(BackendService); - const votingEventName = 'a voting event with votes with comments and replies'; - const replyText = 'this is the REPLY to the comment'; - const replyAuthor = 'I am the author of the reply'; - let votes1: Vote[]; - let credentials1: VoteCredentials; - - let votingEvent; - - let tech1: Technology; - - service - .getVotingEvents({ all: true }) // first delete any votingEvent with the same name - .pipe( - map((votingEvents) => { - const vEvents = votingEvents.filter((ve) => ve.name === votingEventName); - return vEvents.map((ve) => service.cancelVotingEvent(ve._id, true)); - }), - concatMap((cancelVERequests) => (cancelVERequests.length > 0 ? forkJoin(cancelVERequests) : of(null))) - ) - .pipe( - concatMap(() => service.createVotingEvent(votingEventName)), - concatMap(() => service.getVotingEvents()), - tap((votingEvents) => { - const vEvents = votingEvents.filter((ve) => ve.name === votingEventName); - expect(vEvents.length).toBe(1); - credentials1 = { - voterId: { firstName: 'fReplied1', lastName: 'fReplied2' }, - votingEvent: null - }; - votingEvent = vEvents[0]; - credentials1.votingEvent = votingEvent; - }), - concatMap(() => service.openVotingEvent(votingEvent._id)), - concatMap(() => service.getVotingEvent(votingEvent._id)), - // the first voter, Commenter1, saves 1 vote with a comment - tap((vEvent) => { - tech1 = vEvent.technologies[0]; - votes1 = [{ ring: 'hold', technology: tech1, comment: { text: 'comment on the vote' } }]; - credentials1.votingEvent = vEvent; - }), - concatMap(() => service.saveVote(votes1, credentials1)), - concatMap(() => service.getVotesWithCommentsForTechAndEvent(tech1._id, votingEvent._id)), - concatMap((votes: Vote[]) => { - const theVote = votes[0]; - const theCommentId = theVote.comment.id; - const theReply: Comment = { text: replyText, author: replyAuthor }; - return service.addReplyToVoteComment(theVote._id, theReply, theCommentId); - }), - concatMap(() => service.getVotesWithCommentsForTechAndEvent(tech1._id, votingEvent._id)), - tap((votes: Vote[]) => { - const theVote = votes[0]; - expect(theVote.comment).toBeDefined(); - expect(theVote.comment.replies).toBeDefined(); - expect(theVote.comment.replies.length).toBe(1); - expect(theVote.comment.replies[0].text).toBe(replyText); - }) - ) - .subscribe({ - error: (err) => { - console.error('10.2 test the entire voting cycle', err); - done(); - throw new Error('add reply to a comment does not work'); - }, - complete: () => done() - }); - }, 100000); + describe('11 BackendService - add comments and replies to a Technology', () => { + it('11.1 add a tech to a voting event and then add a comment to that technology and a reply to that comment', (done) => { + const service: BackendService = TestBed.get(BackendService); + const votingEventName = 'a voting event with a technology with one comment and one reply'; + + let votingEvent: VotingEvent; + + const newTech: Technology = { + name: 'the new tech to add for comments', + description: 'I am the tech that receives comments', + isnew: true, + quadrant: 'tools' + }; + const theComment = 'I am the comment'; + const theAuthorOfTheComment = 'I am the author of the comment'; + const theReplyToTheComment = 'I am the reply to the comment'; + const theAuthorOfTheReply = 'I am the author of the reply'; + + service + .authenticate(validUser.user, validUser.pwd) + .pipe( + tap((resp) => (testToken = resp)), + concatMap(() => service.getVotingEvents({ all: true })), + map((votingEvents: any) => { + const vEvents = votingEvents.filter((ve) => ve.name === votingEventName); + return vEvents.map((ve) => service.cancelVotingEvent(ve._id, true)); + }), + concatMap((cancelVERequests) => (cancelVERequests.length > 0 ? forkJoin(cancelVERequests) : of(null))) + ) + .pipe( + concatMap(() => service.createVotingEvent(votingEventName)), + concatMap(() => service.getVotingEvents()), + tap((votingEvents) => { + const vEvents = votingEvents.filter((ve) => ve.name === votingEventName); + votingEvent = vEvents[0]; + }), + concatMap(() => service.openVotingEvent(votingEvent._id)), + concatMap(() => service.getVotingEvent(votingEvent._id)), + concatMap(() => service.addTechnologyToVotingEvent(votingEvent._id, newTech)), + concatMap(() => service.getVotingEvent(votingEvent._id)), + map((vEvent) => vEvent.technologies.find((t) => t.description === newTech.description)), + concatMap((tech: Technology) => service.addCommentToTech(votingEvent._id, tech._id, theComment, theAuthorOfTheComment)), + concatMap(() => service.getVotingEvent(votingEvent._id)), + map((vEvent: VotingEvent) => vEvent.technologies.find((t) => t.description === newTech.description)), + tap((tech: Technology) => { + const techComments = tech.comments; + expect(techComments).toBeDefined(); + expect(techComments.length).toBe(1); + expect(techComments[0].text).toBe(theComment); + expect(techComments[0].author).toBe(theAuthorOfTheComment); + expect(techComments[0].id).toBeDefined(); + expect(techComments[0].timestamp).toBeDefined(); + expect(techComments[0].replies).toBeUndefined(); + }), + concatMap((tech: Technology) => { + const commentId = tech.comments[0].id; + const reply: Comment = { + text: theReplyToTheComment, + author: theAuthorOfTheReply + }; + return service.addReplyToTechComment(votingEvent._id, tech._id, reply, commentId); + }), + concatMap(() => service.getVotingEvent(votingEvent._id)), + map((vEvent) => vEvent.technologies.find((t) => t.description === newTech.description)), + tap((tech: Technology) => { + const replies = tech.comments[0].replies; + expect(replies).toBeDefined(); + expect(replies.length).toBe(1); + expect(replies[0].text).toBe(theReplyToTheComment); + expect(replies[0].author).toBe(theAuthorOfTheReply); + expect(replies[0].id).toBeDefined(); + expect(replies[0].timestamp).toBeDefined(); + expect(replies[0].replies).toBeUndefined(); + }) + ) + .subscribe({ + error: (err) => { + console.error('11.1 add a tech to a voting event', err); + throw new Error('add a tech to a voting event does not work'); + }, + complete: () => done() + }); + }, 100000); + }); + + describe('12 BackendService - create a user, authenticate and then delete it', () => { + it('12.1 create a user, authenticate and then delete it', (done) => { + const service: BackendService = TestBed.get(BackendService); + const votingEventName = 'a voting event for a user to log in'; + + let votingEvent: VotingEvent; + const user = 'A new user'; + const pwd = 'my password'; + const firstRole = 'architect'; + const secondRole = 'dev'; + // at the moment the format is one object per role, and the name is repeated is the used has more than one role + // this is to mimic an csv format + const votingEventUser = [{ user, role: firstRole }, { user, role: secondRole }]; + + service + .authenticate(validUser.user, validUser.pwd) + .pipe( + tap((resp) => (testToken = resp)), + concatMap(() => service.deleteUsers([user])), + concatMap(() => service.getVotingEvents({ all: true })), + map((votingEvents: any) => { + const vEvents = votingEvents.filter((ve) => ve.name === votingEventName); + return vEvents.map((ve) => service.cancelVotingEvent(ve._id, true)); + }), + concatMap((cancelVERequests) => (cancelVERequests.length > 0 ? forkJoin(cancelVERequests) : of(null))) + ) + .pipe( + concatMap(() => service.createVotingEvent(votingEventName)), + concatMap(() => service.getVotingEvents()), + tap((votingEvents) => { + const vEvents = votingEvents.filter((ve) => ve.name === votingEventName); + votingEvent = vEvents[0]; + }), + concatMap(() => service.addUsersWithRole(votingEventUser)), + // authinticate first time + concatMap(() => service.authenticateForVotingEvent(user, pwd, firstRole, votingEvent._id)), + tap((resp) => { + expect(resp.token).toBeDefined(); + expect(resp.pwdInserted).toBeTruthy(); + expect(resp.token === testToken).toBeFalsy(); + testToken = resp.token; + }), + // authenticate secondtime + concatMap(() => service.authenticateForVotingEvent(user, pwd, firstRole, votingEvent._id)), + tap((resp) => { + expect(resp.token).toBeDefined(); + expect(resp.pwdInserted).toBeFalsy(); + expect(resp.token === testToken).toBeTruthy(); + }), + // delete the user and then try to authenticate + concatMap(() => service.deleteUsers([user])), + concatMap(() => service.authenticateForVotingEvent(user, pwd, firstRole, votingEvent._id)), + catchError((err) => { + console.error(err); + return of(null); + }) + ) + .subscribe({ + error: (err) => { + console.error('12.1 create a user, authenticate and then delete it', err); + throw new Error('create a user, authenticate and then delete logic does not work'); + }, + complete: () => done() + }); + }, 100000); + }); }); describe('redirect to radar page', () => { diff --git a/src/app/services/backend.service.ts b/src/app/services/backend.service.ts index 2f2a82d..b30f6ea 100644 --- a/src/app/services/backend.service.ts +++ b/src/app/services/backend.service.ts @@ -303,6 +303,24 @@ export class BackendService { return this.http.post(this.url, payload).pipe(catchError(this.handleError)); } + addCommentToTech(_id: string, technologyId: string, comment: string, author: string) { + const payload = this.buildPostPayloadForService(ServiceNames.addCommentToTech); + payload['_id'] = _id; + payload['technologyId'] = technologyId; + payload['comment'] = comment; + payload['author'] = author; + return this.http.post(this.url, payload).pipe(catchError(this.handleError)); + } + + addReplyToTechComment(votingEventId: string, technologyId: string, reply: Comment, commentReceivingReplyId: string) { + const payload = this.buildPostPayloadForService(ServiceNames.addReplyToTechComment); + payload['votingEventId'] = votingEventId; + payload['technologyId'] = technologyId; + payload['reply'] = reply; + payload['commentReceivingReplyId'] = commentReceivingReplyId; + return this.http.post(this.url, payload).pipe(catchError(this.handleError)); + } + getVoters(votingEvent: VotingEvent) { const payload = this.buildPostPayloadForService(ServiceNames.getVoters); payload['votingEvent'] = votingEvent; @@ -327,6 +345,50 @@ export class BackendService { ); } + authenticateForVotingEvent( + user: string, + pwd: string, + role: string, + votingEventId: string + ): Observable<{ token: string; pwdInserted: boolean }> { + const payload = this.buildPostPayloadForService(ServiceNames.authenticateForVotingEvent); + payload['user'] = user; + payload['pwd'] = pwd; + payload['role'] = role; + payload['votingEventId'] = votingEventId; + return this.http.post(this.url, payload).pipe( + map((resp: any) => { + const r = this.handleReponseDefault(resp); + return r; + }), + catchError(this.handleError) + ); + } + + addUsersWithRole(users: { user: string; role: string }[]) { + const payload = this.buildPostPayloadForService(ServiceNames.addUsersWithRole); + payload['users'] = users; + return this.http.post(this.url, payload).pipe( + map((resp: any) => { + const r = this.handleReponseDefault(resp); + return r; + }), + catchError(this.handleError) + ); + } + + deleteUsers(users: string[]) { + const payload = this.buildPostPayloadForService(ServiceNames.deleteUsers); + payload['users'] = users; + return this.http.post(this.url, payload).pipe( + map((resp: any) => { + const r = this.handleReponseDefault(resp); + return r; + }), + catchError(this.handleError) + ); + } + getConfiguration(user?: string) { const payload = this.buildPostPayloadForService(ServiceNames.getConfiguration); payload['user'] = user; diff --git a/src/app/services/service-names.ts b/src/app/services/service-names.ts index fb237f9..90fe331 100644 --- a/src/app/services/service-names.ts +++ b/src/app/services/service-names.ts @@ -17,6 +17,8 @@ export enum ServiceNames { closeVotingEvent, cancelVotingEvent, addNewTechnologyToEvent, + addCommentToTech, + addReplyToTechComment, getVoters, calculateBlips, calculateBlipsFromAllEvents, @@ -24,5 +26,8 @@ export enum ServiceNames { closeForRevote, getConfiguration, authenticate, + authenticateForVotingEvent, + addUsersWithRole, + deleteUsers, saveLogInfo } From d54f9427c1c848cd25f4b4813f97a0a27dff79cc Mon Sep 17 00:00:00 2001 From: Enrico PIccinin Date: Tue, 2 Jul 2019 21:00:27 +0200 Subject: [PATCH 6/7] refactor(roles to groups): roles renamed to groups roles have been renamed as groups --- .talismanrc | 2 +- src/app/models/voting-event-step.ts | 7 +++-- .../vote/vote/vote-dialogue.component.spec.ts | 2 +- .../modules/vote/vote/vote.component.spec.ts | 3 +++ src/app/services/backend.service.spec.ts | 27 +++++++++++++++---- src/app/services/backend.service.ts | 10 ++++--- 6 files changed, 38 insertions(+), 13 deletions(-) diff --git a/.talismanrc b/.talismanrc index 2a7da69..75bfb31 100644 --- a/.talismanrc +++ b/.talismanrc @@ -17,7 +17,7 @@ fileignoreconfig: checksum: 510ff54745a0315fdaa5de0cf6923cc4e30081789e358120baf277f8cd1b5379 ignore_detectors: [] - filename: src/app/services/backend.service.spec.ts - checksum: 4fb42ea12edc56c266d61835bd6ffd096c261441554f4c25b37864031299915a + checksum: cac6925ede89879ba601e3e08bc3e9aa8c58c61863eb82f914c2829a1e5c0de1 ignore_detectors: [] - filename: docs/images/vote_process.gif checksum: c3a82314b7db3029e5dd9b4226bde884f472561112b4287b5193eeeab1a7cf75 diff --git a/src/app/models/voting-event-step.ts b/src/app/models/voting-event-step.ts index 017e86b..9006405 100644 --- a/src/app/models/voting-event-step.ts +++ b/src/app/models/voting-event-step.ts @@ -1,7 +1,10 @@ export type IdentificationTypeNames = 'nickname' | 'login'; export type ActionNames = 'vote' | 'conversation' | 'recommendation'; +export type TechSelectLogic = 'TechWithComments' | 'TechUncertain'; export interface VotingEventStep { - identification: { name: IdentificationTypeNames; role?: any }; - action: { name: ActionNames; commentOnVoteBlocked?: boolean }; + name: string; + description?: string; + identification: { name: IdentificationTypeNames; roles?: string[] }; + action: { name: ActionNames; commentOnVoteBlocked?: boolean; techSelectLogic?: TechSelectLogic }; } diff --git a/src/app/modules/vote/vote/vote-dialogue.component.spec.ts b/src/app/modules/vote/vote/vote-dialogue.component.spec.ts index 09f40bf..abedfe5 100644 --- a/src/app/modules/vote/vote/vote-dialogue.component.spec.ts +++ b/src/app/modules/vote/vote/vote-dialogue.component.spec.ts @@ -26,7 +26,7 @@ class MockAppSessionService { name: 'an event', status: 'open', creationTS: 'abc', - flow: { steps: [{ identification: { name: 'nickname' }, action: { name: 'vote' } }] } + flow: { steps: [{ name: 'the flow', identification: { name: 'nickname' }, action: { name: 'vote' } }] } }; } diff --git a/src/app/modules/vote/vote/vote.component.spec.ts b/src/app/modules/vote/vote/vote.component.spec.ts index 50e4f33..d6a5c2e 100644 --- a/src/app/modules/vote/vote/vote.component.spec.ts +++ b/src/app/modules/vote/vote/vote.component.spec.ts @@ -77,6 +77,9 @@ class MockAppSessionService { getSelectedVotingEvent() { return this.selectedVotingEvent; } + getSelectedTechnology() { + return null; + } } describe('VoteComponent', () => { diff --git a/src/app/services/backend.service.spec.ts b/src/app/services/backend.service.spec.ts index 6abfac7..adc4ab4 100644 --- a/src/app/services/backend.service.spec.ts +++ b/src/app/services/backend.service.spec.ts @@ -16,6 +16,7 @@ import { JwtModule, JWT_OPTIONS } from '@auth0/angular-jwt'; import { apiDomain } from '../app.module'; import { logError } from '../utils/utils'; import { Comment } from '../models/comment'; +import { VotingEventFlow } from '../models/voting-event-flow'; describe('BackendService', () => { let testToken; @@ -804,6 +805,22 @@ describe('BackendService', () => { const pwd = 'my password'; const firstRole = 'architect'; const secondRole = 'dev'; + const firstStepName = 'first step'; + const secondStepName = 'second step'; + const votingEventFlow: VotingEventFlow = { + steps: [ + { + name: firstStepName, + identification: { name: 'nickname' }, + action: { name: 'vote', commentOnVoteBlocked: false } + }, + { + name: secondStepName, + identification: { name: 'login', roles: [firstRole] }, + action: { name: 'conversation' } + } + ] + }; // at the moment the format is one object per role, and the name is repeated is the used has more than one role // this is to mimic an csv format const votingEventUser = [{ user, role: firstRole }, { user, role: secondRole }]; @@ -821,7 +838,7 @@ describe('BackendService', () => { concatMap((cancelVERequests) => (cancelVERequests.length > 0 ? forkJoin(cancelVERequests) : of(null))) ) .pipe( - concatMap(() => service.createVotingEvent(votingEventName)), + concatMap(() => service.createVotingEvent(votingEventName, votingEventFlow)), concatMap(() => service.getVotingEvents()), tap((votingEvents) => { const vEvents = votingEvents.filter((ve) => ve.name === votingEventName); @@ -829,7 +846,7 @@ describe('BackendService', () => { }), concatMap(() => service.addUsersWithRole(votingEventUser)), // authinticate first time - concatMap(() => service.authenticateForVotingEvent(user, pwd, firstRole, votingEvent._id)), + concatMap(() => service.authenticateForVotingEvent(user, pwd, votingEvent._id, firstStepName)), tap((resp) => { expect(resp.token).toBeDefined(); expect(resp.pwdInserted).toBeTruthy(); @@ -837,7 +854,7 @@ describe('BackendService', () => { testToken = resp.token; }), // authenticate secondtime - concatMap(() => service.authenticateForVotingEvent(user, pwd, firstRole, votingEvent._id)), + concatMap(() => service.authenticateForVotingEvent(user, pwd, votingEvent._id, firstStepName)), tap((resp) => { expect(resp.token).toBeDefined(); expect(resp.pwdInserted).toBeFalsy(); @@ -845,9 +862,9 @@ describe('BackendService', () => { }), // delete the user and then try to authenticate concatMap(() => service.deleteUsers([user])), - concatMap(() => service.authenticateForVotingEvent(user, pwd, firstRole, votingEvent._id)), + concatMap(() => service.authenticateForVotingEvent(user, pwd, votingEvent._id, firstStepName)), catchError((err) => { - console.error(err); + expect(err.errorCode).toBe(ERRORS.userUnknown); return of(null); }) ) diff --git a/src/app/services/backend.service.ts b/src/app/services/backend.service.ts index b30f6ea..192b958 100644 --- a/src/app/services/backend.service.ts +++ b/src/app/services/backend.service.ts @@ -16,6 +16,7 @@ import { ERRORS } from './errors'; import { logError } from '../utils/utils'; import { inspect } from 'util'; import { Comment } from '../models/comment'; +import { VotingEventFlow } from '../models/voting-event-flow'; interface BackEndError { errorCode: string; @@ -254,9 +255,10 @@ export class BackendService { ); } - createVotingEvent(name: string) { + createVotingEvent(name: string, flow?: VotingEventFlow) { const payload = this.buildPostPayloadForService(ServiceNames.createVotingEvent); payload['name'] = name; + payload['flow'] = flow; return this.http.post(this.url, payload).pipe( map((resp: RespFromBackend) => { return resp; @@ -348,14 +350,14 @@ export class BackendService { authenticateForVotingEvent( user: string, pwd: string, - role: string, - votingEventId: string + votingEventId: string, + flowStepName: string ): Observable<{ token: string; pwdInserted: boolean }> { const payload = this.buildPostPayloadForService(ServiceNames.authenticateForVotingEvent); payload['user'] = user; payload['pwd'] = pwd; - payload['role'] = role; payload['votingEventId'] = votingEventId; + payload['flowStepName'] = flowStepName; return this.http.post(this.url, payload).pipe( map((resp: any) => { const r = this.handleReponseDefault(resp); From f939ede852d4362a27db8d882859b8dce7bf1cb0 Mon Sep 17 00:00:00 2001 From: Enrico PIccinin Date: Wed, 3 Jul 2019 19:40:02 +0200 Subject: [PATCH 7/7] feat(nickname.component): nickname component introduced and feature toggle for voting event flow It is possible to enable the voting event flow setting the property "enableVotingEventFlow" to true (boolean) in the default configuration defined in the mongo db --- src/app/app-routes.ts | 5 + src/app/app-session.service.ts | 9 ++ src/app/app.component.ts | 51 ++++++--- src/app/models/credentials.ts | 4 + src/app/modules/login/login.module.ts | 3 +- .../login/nickname/nickname.component.html | 26 +++++ .../login/nickname/nickname.component.scss | 53 +++++++++ .../login/nickname/nickname.component.spec.ts | 44 +++++++ .../login/nickname/nickname.component.ts | 108 ++++++++++++++++++ .../start-voting-session.component.ts | 5 +- src/app/modules/vote/vote/vote.component.ts | 33 ++++-- src/app/utils/voting-event-flow.util.ts | 2 +- 12 files changed, 312 insertions(+), 31 deletions(-) create mode 100644 src/app/models/credentials.ts create mode 100644 src/app/modules/login/nickname/nickname.component.html create mode 100644 src/app/modules/login/nickname/nickname.component.scss create mode 100644 src/app/modules/login/nickname/nickname.component.spec.ts create mode 100644 src/app/modules/login/nickname/nickname.component.ts diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index e5986e5..300136b 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -3,6 +3,7 @@ import { LoginComponent } from './modules/login/login.component'; import { ErrorComponent } from './components/error/error.component'; import { VotingEventSelectComponent } from './components/voting-event-select/voting-event-select.component'; import { LoginVotingEventComponent } from './modules/login/login-voting-event/login-voting-event.component'; +import { NicknameComponent } from './modules/login/nickname/nickname.component'; export const appRoutes: Routes = [ { @@ -26,6 +27,10 @@ export const appRoutes: Routes = [ path: 'login-voting-event', component: LoginVotingEventComponent }, + { + path: 'nickname', + component: NicknameComponent + }, { path: 'vote', loadChildren: './modules/vote/vote.module#VoteModule' diff --git a/src/app/app-session.service.ts b/src/app/app-session.service.ts index 827442c..b464b5d 100644 --- a/src/app/app-session.service.ts +++ b/src/app/app-session.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { VotingEvent } from 'src/app/models/voting-event'; import { Technology } from './models/technology'; +import { Credentials } from './models/credentials'; @Injectable({ providedIn: 'root' @@ -9,6 +10,7 @@ export class AppSessionService { private votingEvents: VotingEvent[]; private selectedVotingEvent: VotingEvent; private selectedTechnology: Technology; + private credentials: Credentials; constructor() {} @@ -32,4 +34,11 @@ export class AppSessionService { setSelectedTechnology(technology: Technology) { this.selectedTechnology = technology; } + + getCredentials() { + return this.credentials; + } + setCredentials(credentials: Credentials) { + this.credentials = credentials; + } } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 6876b4a..5f11298 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -5,7 +5,8 @@ import { BackendService } from './services/backend.service'; import { ErrorService } from './services/error.service'; import { AppSessionService } from './app-session.service'; import { getIdentificationRoute } from './utils/voting-event-flow.util'; -import { map } from 'rxjs/operators'; +import { map, tap } from 'rxjs/operators'; +import { ConfigurationService } from './services/configuration.service'; @Component({ selector: 'byor-root', @@ -19,27 +20,39 @@ export class AppComponent implements OnInit { private router: Router, private backend: BackendService, public errorService: ErrorService, - private appSession: AppSessionService + private appSession: AppSessionService, + private configurationService: ConfigurationService ) {} ngOnInit() { - this.backend - .getVotingEvents() - .pipe(map((votingEvents) => votingEvents.filter((ve) => ve.status === 'open'))) - .subscribe((votingEvents) => { - if (!votingEvents || votingEvents.length === 0) { - this.errorService.setError(new Error('There are no Voting Events open')); - this.router.navigate(['error']); - } else if (votingEvents.length === 1) { - const votingEvent = votingEvents[0]; - this.appSession.setSelectedVotingEvent(votingEvent); - const route = getIdentificationRoute(votingEvent); - this.router.navigate([route]); - } else { - this.appSession.setVotingEvents(votingEvents); - this.router.navigate(['selectVotingEvent']); - } - }); + this.configurationService + .defaultConfiguration() + .pipe( + tap((config) => { + if (config.enableVotingEventFlow) { + this.backend + .getVotingEvents() + .pipe(map((votingEvents) => votingEvents.filter((ve) => ve.status === 'open'))) + .subscribe((votingEvents) => { + if (!votingEvents || votingEvents.length === 0) { + this.errorService.setError(new Error('There are no Voting Events open')); + this.router.navigate(['error']); + } else if (votingEvents.length === 1) { + const votingEvent = votingEvents[0]; + this.appSession.setSelectedVotingEvent(votingEvent); + const route = getIdentificationRoute(votingEvent); + this.router.navigate([route]); + } else { + this.appSession.setVotingEvents(votingEvents); + this.router.navigate(['selectVotingEvent']); + } + }); + } else { + this.router.navigate(['vote']); + } + }) + ) + .subscribe(); } goToAdminPage() { diff --git a/src/app/models/credentials.ts b/src/app/models/credentials.ts new file mode 100644 index 0000000..5056be8 --- /dev/null +++ b/src/app/models/credentials.ts @@ -0,0 +1,4 @@ +export interface Credentials { + userId?: string; + nickname?: string; +} diff --git a/src/app/modules/login/login.module.ts b/src/app/modules/login/login.module.ts index 997d0d9..e451f64 100644 --- a/src/app/modules/login/login.module.ts +++ b/src/app/modules/login/login.module.ts @@ -5,9 +5,10 @@ import { AuthGuard } from './auth.guard'; import { AuthService } from './auth.service'; import { AppMaterialModule } from '../../app-material.module'; import { LoginVotingEventComponent } from './login-voting-event/login-voting-event.component'; +import { NicknameComponent } from './nickname/nickname.component'; @NgModule({ - declarations: [LoginComponent, LoginVotingEventComponent], + declarations: [LoginComponent, LoginVotingEventComponent, NicknameComponent], providers: [AuthGuard, AuthService], imports: [CommonModule, AppMaterialModule], exports: [LoginComponent] diff --git a/src/app/modules/login/nickname/nickname.component.html b/src/app/modules/login/nickname/nickname.component.html new file mode 100644 index 0000000..317c12a --- /dev/null +++ b/src/app/modules/login/nickname/nickname.component.html @@ -0,0 +1,26 @@ +
+
+ Voting for event: {{appSession.getSelectedVotingEvent().name}} +
+
+ +
+
+ + +
+ +
+ +
+ +
+ +
+ \ No newline at end of file diff --git a/src/app/modules/login/nickname/nickname.component.scss b/src/app/modules/login/nickname/nickname.component.scss new file mode 100644 index 0000000..ece851c --- /dev/null +++ b/src/app/modules/login/nickname/nickname.component.scss @@ -0,0 +1,53 @@ +@import "../../../styles/abstracts/mixins"; + +$login-sections-max-width: 480px; + +.nickname-section { + margin: 1.3rem auto; + padding: 0 1.3rem; + max-width: $login-sections-max-width; + + .event-selection { + display: block; + } + + mat-form-field { + width: 100%; + } + + .nickname { + margin-bottom: 2rem; + + .nickname-input { + width: 100%; + @include byor-input; + } + + label { + display: block; + margin-bottom: .3rem; + } + } + + .button-disabled { + border: none; + } + + .button-text { + color: #ffffff; + } +} + +.banner-section { + margin: 0 auto; + padding: 6rem 1.3rem; + max-width: $login-sections-max-width; + text-align: center; + + a { + border-bottom: none; + img { + max-width: 100%; + } + } +} \ No newline at end of file diff --git a/src/app/modules/login/nickname/nickname.component.spec.ts b/src/app/modules/login/nickname/nickname.component.spec.ts new file mode 100644 index 0000000..c978ae4 --- /dev/null +++ b/src/app/modules/login/nickname/nickname.component.spec.ts @@ -0,0 +1,44 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NicknameComponent } from './nickname.component'; +import { RouterTestingModule } from '@angular/router/testing'; +import { AppMaterialModule } from 'src/app/app-material.module'; +import { HttpClientModule } from '@angular/common/http'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { VotingEvent } from 'src/app/models/voting-event'; +import { AppSessionService } from 'src/app/app-session.service'; + +class MockAppSessionService { + private votingEvent: VotingEvent; + + constructor() { + this.votingEvent = { _id: '123', name: 'an event', status: 'open', creationTS: 'abc' }; + } + + getSelectedVotingEvent() { + return this.votingEvent; + } +} + +describe('NicknameComponent', () => { + let component: NicknameComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [NicknameComponent], + imports: [RouterTestingModule, AppMaterialModule, HttpClientModule, BrowserAnimationsModule], + providers: [{ provide: AppSessionService, useClass: MockAppSessionService }] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(NicknameComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/modules/login/nickname/nickname.component.ts b/src/app/modules/login/nickname/nickname.component.ts new file mode 100644 index 0000000..25ad127 --- /dev/null +++ b/src/app/modules/login/nickname/nickname.component.ts @@ -0,0 +1,108 @@ +import { Component, OnInit, AfterViewInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Observable, Subject, fromEvent, Subscription, NEVER } from 'rxjs'; +import { shareReplay, map, share, switchMap, tap, filter } from 'rxjs/operators'; + +import { AppSessionService } from 'src/app/app-session.service'; +import { ConfigurationService } from 'src/app/services/configuration.service'; +import { BackendService } from 'src/app/services/backend.service'; +import { VoteCredentials } from 'src/app/models/vote-credentials'; +import { ErrorService } from 'src/app/services/error.service'; +import { logError } from 'src/app/utils/utils'; + +@Component({ + selector: 'byor-nickname', + templateUrl: './nickname.component.html', + styleUrls: ['./nickname.component.scss'] +}) +export class NicknameComponent implements AfterViewInit, OnDestroy, OnInit { + constructor( + private router: Router, + private errorService: ErrorService, + public appSession: AppSessionService, + private configurationService: ConfigurationService, + private backend: BackendService + ) {} + + isValidInputData$: Observable; + message$ = new Subject(); + configuration$: Observable; + + goToVoteSubscription: Subscription; + + @ViewChild('nickname') voterFirstName: ElementRef; + @ViewChild('startButton', { read: ElementRef }) startButtonRef: ElementRef; + + ngOnInit() { + this.configuration$ = this.configurationService.defaultConfiguration().pipe(shareReplay(1)); + } + + ngAfterViewInit() { + // notify when nickname is changed + const nickname$ = fromEvent(this.voterFirstName.nativeElement, 'keyup').pipe(map(() => this.voterFirstName.nativeElement.value)); + + const startButtonClick$ = fromEvent(this.startButtonRef.nativeElement, 'click'); + + // the main subscription + this.goToVoteSubscription = this.goToVote$(nickname$, startButtonClick$).subscribe( + (nickname) => { + this.appSession.setCredentials({ nickname }); + this.router.navigate(['vote/start']); + }, + (error) => { + logError(error); + let _errMsg = error; + if (error.message) { + _errMsg = error.message; + } + this.errorService.setError(_errMsg); + this.errorService.setErrorMessage(error); + this.router.navigate(['error']); + } + ); + } + + ngOnDestroy() { + if (this.goToVoteSubscription) { + this.goToVoteSubscription.unsubscribe(); + } + } + + // this method takes in input all Observable created out of DOM events which are relevant for this Component + // in this way we can easily test this logic with marble tests + goToVote$(nickname$: Observable, startButtonClick$: Observable) { + // notifies when the input data provided changes - the value notified is true of false + // depending on the fact that the input data is valid or not + this.isValidInputData$ = nickname$.pipe( + map((nickname) => this.isNicknameValid(nickname)), + share() // share() is used since this Observable is used also on the Html template + ); + + const clickOnVote$ = nickname$.pipe( + switchMap((nickname) => (this.isNicknameValid(nickname) ? startButtonClick$.pipe(map(() => nickname)) : NEVER)) + ); + + // notifies when the user has clicked to go to voting session and he has not voted yet + return clickOnVote$.pipe( + switchMap((nickname) => { + const votingEvent = this.appSession.getSelectedVotingEvent(); + // to remove when we refactor hasAlreadyVoted + const oldCredentials: VoteCredentials = { voterId: { firstName: nickname, lastName: '' }, votingEvent }; + return this.backend.hasAlreadyVoted(oldCredentials).pipe( + tap((hasAlreadyVoted) => { + if (hasAlreadyVoted) { + this.message$.next(`You have already voted for ${votingEvent.name}`); + } + }), + filter((hasAlreadyVoted) => !hasAlreadyVoted), + map(() => nickname) + ); + }) + ); + } + + isNicknameValid(nickname: string) { + return nickname && nickname.trim().length > 0; + } +} diff --git a/src/app/modules/vote/start-voting-session/start-voting-session.component.ts b/src/app/modules/vote/start-voting-session/start-voting-session.component.ts index c884af8..9f59e4f 100644 --- a/src/app/modules/vote/start-voting-session/start-voting-session.component.ts +++ b/src/app/modules/vote/start-voting-session/start-voting-session.component.ts @@ -8,6 +8,7 @@ import { VoteService } from '../services/vote.service'; import { ErrorService } from 'src/app/services/error.service'; import { ConfigurationService } from 'src/app/services/configuration.service'; import { logError } from 'src/app/utils/utils'; +import { AppSessionService } from 'src/app/app-session.service'; @Component({ selector: 'byor-start-voting-session', @@ -34,7 +35,8 @@ export class StartVotingSessionComponent implements AfterViewInit, OnDestroy, On private router: Router, private voteService: VoteService, private errorService: ErrorService, - private configurationService: ConfigurationService + private configurationService: ConfigurationService, + private appSession: AppSessionService ) {} openVotingEvents$: Observable>; @@ -83,6 +85,7 @@ export class StartVotingSessionComponent implements AfterViewInit, OnDestroy, On .subscribe( (credentials) => { this.voteService.credentials = credentials; + this.appSession.setSelectedVotingEvent(credentials.votingEvent); this.router.navigate(['vote/start']); }, (error) => { diff --git a/src/app/modules/vote/vote/vote.component.ts b/src/app/modules/vote/vote/vote.component.ts index bd8fab2..fe2fcc5 100644 --- a/src/app/modules/vote/vote/vote.component.ts +++ b/src/app/modules/vote/vote/vote.component.ts @@ -23,6 +23,8 @@ import { Comment } from 'src/app/models/comment'; import { logError } from 'src/app/utils/utils'; import { AppSessionService } from 'src/app/app-session.service'; import { getActionName } from 'src/app/utils/voting-event-flow.util'; +import { ninvoke } from 'q'; +import { ConfigurationService } from 'src/app/services/configuration.service'; @Component({ selector: 'byor-vote', @@ -68,7 +70,8 @@ export class VoteComponent implements AfterViewInit, OnDestroy { private errorService: ErrorService, public dialog: MatDialog, private voteService: VoteService, - private appSession: AppSessionService + private appSession: AppSessionService, + private configurationService: ConfigurationService ) {} ngAfterViewInit() { @@ -112,7 +115,8 @@ export class VoteComponent implements AfterViewInit, OnDestroy { } getTechnologies() { - const votingEvent = this.appSession.getSelectedVotingEvent(); + // @todo remove "|| this.voteService.credentials.votingEvent" once the enableVotingEventFlow toggle is removed + const votingEvent = this.appSession.getSelectedVotingEvent() || this.voteService.credentials.votingEvent; return this.backEnd.getVotingEvent(votingEvent._id).pipe( map((event) => { let technologies = event.technologies; @@ -146,7 +150,7 @@ export class VoteComponent implements AfterViewInit, OnDestroy { } createNewTechnology(name: string, quadrant: string) { - const votingEvent = this.voteService.credentials.votingEvent; + const votingEvent = this.appSession.getSelectedVotingEvent(); const technology: Technology = { name: name, isnew: true, @@ -189,7 +193,7 @@ export class VoteComponent implements AfterViewInit, OnDestroy { } goToConversation(technology: Technology) { - this.voteService.technology = technology; + this.appSession.setSelectedTechnology(technology); this.router.navigate(['vote/conversation']); } @@ -205,12 +209,22 @@ export class VoteComponent implements AfterViewInit, OnDestroy { } saveVotes() { - this.backEnd.saveVote(this.votes, this.voteService.credentials).subscribe( - (resp) => { + const credentials = this.appSession.getCredentials(); + const votingEvent = this.appSession.getSelectedVotingEvent(); + let voterIdentification; + let oldCredentials: VoteCredentials; + if (credentials) { + voterIdentification = credentials.nickname || credentials.userId; + oldCredentials = { voterId: { firstName: voterIdentification, lastName: '' }, votingEvent }; + } else { + oldCredentials = this.voteService.credentials; + voterIdentification = oldCredentials.voterId.firstName + ' ' + oldCredentials.voterId.lastName; + } + combineLatest(this.backEnd.saveVote(this.votes, oldCredentials), this.configurationService.defaultConfiguration()).subscribe( + ([resp, config]) => { if (resp.error) { if (resp.error.errorCode === 'V-01') { - const voterName = this.getVoterFirstLastName(this.voteService.credentials); - this.messageVote = ` ${voterName} has already voted`; + this.messageVote = ` ${voterIdentification} has already voted`; } else { this.messageVote = `Vote could not be saved - look at the browser console - maybe there is something there`; @@ -221,7 +235,8 @@ export class VoteComponent implements AfterViewInit, OnDestroy { }); dialogRef.afterClosed().subscribe((result) => { - this.router.navigate(['/vote']); + const route = config.enableVotingEventFlow ? 'nickname' : '/vote'; + this.router.navigate([route]); }); } }, diff --git a/src/app/utils/voting-event-flow.util.ts b/src/app/utils/voting-event-flow.util.ts index 3801319..afce2f9 100644 --- a/src/app/utils/voting-event-flow.util.ts +++ b/src/app/utils/voting-event-flow.util.ts @@ -10,7 +10,7 @@ export function getIdentificationRoute(votingEvent: VotingEvent) { if (identificationType === 'login') { route = 'login-voting-event'; } else if (identificationType === 'nickname') { - route = 'vote'; + route = 'nickname'; } else { throw new Error(`No route defined for identification type ${identificationType}`); }