diff --git a/.talismanrc b/.talismanrc index 4ba14a9..fd22003 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: cac6925ede89879ba601e3e08bc3e9aa8c58c61863eb82f914c2829a1e5c0de1 ignore_detectors: [] - filename: docs/images/vote_process.gif checksum: c3a82314b7db3029e5dd9b4226bde884f472561112b4287b5193eeeab1a7cf75 @@ -34,6 +34,9 @@ fileignoreconfig: - filename: config/byor.sh.sample checksum: f6ea581aca0260b7f9d098b5715cfaed8702e3ff4de3b2e8f9e4429db9022982 ignore_detectors: [] +- filename: src/app/modules/login/login-voting-event/login-voting-event.component.ts + checksum: c56211684d1af6785d67fe6deda91af2b405037c80161090561eb388c642e5e7 + ignore_detectors: [] - filename: .make/cd/deploy_aws.sh checksum: 2740b006c659977adb8595efe4c3802f14e093075f03c7a84552af96fd4e09b1 ignore_detectors: [] diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index c9cdfc9..300136b 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -1,6 +1,9 @@ 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'; +import { NicknameComponent } from './modules/login/nickname/nickname.component'; export const appRoutes: Routes = [ { @@ -17,11 +20,23 @@ export const appRoutes: Routes = [ loadChildren: './modules/admin/admin.module#AdminModule' }, { - path: 'vote', - loadChildren: './modules/vote/vote.module#VoteModule' + path: 'selectVotingEvent', + component: VotingEventSelectComponent + }, + { + path: 'login-voting-event', + component: LoginVotingEventComponent }, { - path: '**', - redirectTo: 'vote', + path: 'nickname', + component: NicknameComponent }, + { + 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..b464b5d --- /dev/null +++ b/src/app/app-session.service.ts @@ -0,0 +1,44 @@ +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' +}) +export class AppSessionService { + private votingEvents: VotingEvent[]; + private selectedVotingEvent: VotingEvent; + private selectedTechnology: Technology; + private credentials: Credentials; + + 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; + } + + getCredentials() { + return this.credentials; + } + setCredentials(credentials: Credentials) { + this.credentials = credentials; + } +} 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..5f11298 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,16 +1,59 @@ -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, tap } from 'rxjs/operators'; +import { ConfigurationService } from './services/configuration.service'; @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, + private configurationService: ConfigurationService + ) {} + + ngOnInit() { + 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() { 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..c56f12b --- /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..c2567b6 --- /dev/null +++ b/src/app/components/voting-event-select/voting-event-select.component.spec.ts @@ -0,0 +1,43 @@ +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'; +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; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [AppMaterialModule, RouterTestingModule, BrowserAnimationsModule], + declarations: [VotingEventSelectComponent], + providers: [{ provide: AppSessionService, useClass: MockAppSessionService }] + }).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/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/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/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/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..9006405 --- /dev/null +++ b/src/app/models/voting-event-step.ts @@ -0,0 +1,10 @@ +export type IdentificationTypeNames = 'nickname' | 'login'; +export type ActionNames = 'vote' | 'conversation' | 'recommendation'; +export type TechSelectLogic = 'TechWithComments' | 'TechUncertain'; + +export interface VotingEventStep { + name: string; + description?: string; + identification: { name: IdentificationTypeNames; roles?: string[] }; + action: { name: ActionNames; commentOnVoteBlocked?: boolean; techSelectLogic?: TechSelectLogic }; +} 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-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..00460f9 --- /dev/null +++ b/src/app/modules/conversation/conversation/conversation.component.html @@ -0,0 +1,32 @@ +{{appSession.getSelectedTechnology().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..d0d878d --- /dev/null +++ b/src/app/modules/conversation/conversation/conversation.component.spec.ts @@ -0,0 +1,180 @@ +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'; +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 = [ + { + 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' +}; + +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'; +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 }, + { provide: AppSessionService, useClass: MockAppSessionService } + ] + }).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..4ca79de --- /dev/null +++ b/src/app/modules/conversation/conversation/conversation.component.ts @@ -0,0 +1,231 @@ +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'; +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 { + 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, 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.appSession.getSelectedTechnology(); + const votingEvent = this.appSession.getSelectedVotingEvent(); + 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.authService.user; + 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); + } + + 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/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/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..e7eac64 --- /dev/null +++ b/src/app/modules/login/login-voting-event/login-voting-event.component.spec.ts @@ -0,0 +1,32 @@ +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; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [LoginVotingEventComponent], + imports: [BrowserAnimationsModule, HttpClientTestingModule, RouterTestingModule, AppMaterialModule], + providers: [AppSessionService] + }).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..e451f64 100644 --- a/src/app/modules/login/login.module.ts +++ b/src/app/modules/login/login.module.ts @@ -4,21 +4,13 @@ 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'; +import { NicknameComponent } from './nickname/nickname.component'; @NgModule({ - declarations: [ - LoginComponent, - ], - providers: [ - AuthGuard, - AuthService, - ], - imports: [ - CommonModule, - AppMaterialModule, - ], - exports: [ - LoginComponent, - ] + declarations: [LoginComponent, LoginVotingEventComponent, NicknameComponent], + providers: [AuthGuard, AuthService], + imports: [CommonModule, AppMaterialModule], + exports: [LoginComponent] }) -export class LoginModule { } +export class LoginModule {} 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/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/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-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-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..abedfe5 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: [{ name: 'the flow', 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.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.spec.ts b/src/app/modules/vote/vote/vote.component.spec.ts index bf8d08b..d6a5c2e 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,20 @@ class MockVoteService { }; } } +class MockAppSessionService { + private selectedVotingEvent: VotingEvent; + + constructor() { + this.selectedVotingEvent = { _id: '123', name: 'an event', status: 'open', creationTS: 'abc' }; + } + + getSelectedVotingEvent() { + return this.selectedVotingEvent; + } + getSelectedTechnology() { + return null; + } +} describe('VoteComponent', () => { let component: VoteComponent; @@ -58,13 +89,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 +120,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 +145,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 +161,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 +190,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 +216,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 +230,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 +242,7 @@ describe('VoteComponent', () => { }; component.addVote(vote); const isExist = component.isAlreadyVoted('random'); - expect(isExist).toBeFalsy() - + expect(isExist).toBeFalsy(); }); - }) + }); }); diff --git a/src/app/modules/vote/vote/vote.component.ts b/src/app/modules/vote/vote/vote.component.ts index 75c0df6..fe2fcc5 100644 --- a/src/app/modules/vote/vote/vote.component.ts +++ b/src/app/modules/vote/vote/vote.component.ts @@ -21,6 +21,10 @@ 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'; +import { ninvoke } from 'q'; +import { ConfigurationService } from 'src/app/services/configuration.service'; @Component({ selector: 'byor-vote', @@ -65,7 +69,9 @@ export class VoteComponent implements AfterViewInit, OnDestroy { private router: Router, private errorService: ErrorService, public dialog: MatDialog, - private voteService: VoteService + private voteService: VoteService, + private appSession: AppSessionService, + private configurationService: ConfigurationService ) {} ngAfterViewInit() { @@ -109,7 +115,8 @@ export class VoteComponent implements AfterViewInit, OnDestroy { } getTechnologies() { - const votingEvent = this.voteService.credentials.votingEvent; + // @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; @@ -127,8 +134,23 @@ export class VoteComponent implements AfterViewInit, OnDestroy { return this.votes.filter((v) => v.ring === ring); } + technologySelected(technology: Technology) { + 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 { + throw new Error(`No route for action name "${actionName}"`); + } + } + createNewTechnology(name: string, quadrant: string) { - const votingEvent = this.voteService.credentials.votingEvent; + const votingEvent = this.appSession.getSelectedVotingEvent(); const technology: Technology = { name: name, isnew: true, @@ -170,6 +192,11 @@ export class VoteComponent implements AfterViewInit, OnDestroy { }); } + goToConversation(technology: Technology) { + this.appSession.setSelectedTechnology(technology); + this.router.navigate(['vote/conversation']); + } + addVote(vote: Vote) { this.votes.push(vote); this.votes$.next(this.votes); @@ -182,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`; @@ -198,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/services/backend.service.spec.ts b/src/app/services/backend.service.spec.ts index 5838172..adc4ab4 100644 --- a/src/app/services/backend.service.spec.ts +++ b/src/app/services/backend.service.spec.ts @@ -15,6 +15,8 @@ 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'; +import { VotingEventFlow } from '../models/voting-event-flow'; describe('BackendService', () => { let testToken; @@ -28,7 +30,7 @@ describe('BackendService', () => { return { tokenGetter: getTestToken, whitelistedDomains: apiDomain() - } + }; } const validUser = { user: 'abc', pwd: '123' }; @@ -171,7 +173,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() @@ -179,77 +180,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; @@ -344,7 +345,6 @@ describe('BackendService', () => { }, (err) => { logError('5.1 authenticate a valid user ' + err); - done(); throw new Error('the authenticate logic has some issues'); }, () => done() @@ -353,83 +353,79 @@ 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' }; - - 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); + 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); + 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' }; - - 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); + 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); + 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); + 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); + throw new Error('the getConfiguration logic has some issues'); + }, + () => done() + ); + }, 10000); }); describe('7 BackendService - saveLogInfo', () => { @@ -445,7 +441,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() @@ -503,7 +498,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() @@ -552,7 +546,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() @@ -641,9 +634,244 @@ describe('BackendService', () => { ) .subscribe({ error: (err) => { - logError('2.1 test the entire voting cycle ' + err); - done(); - throw new Error('the voting cycle does not work'); + console.error('10.1 test the entire voting cycle', err); + 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); + }); + + 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'; + 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 }]; + + 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, votingEventFlow)), + 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, votingEvent._id, firstStepName)), + 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, votingEvent._id, firstStepName)), + 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, votingEvent._id, firstStepName)), + catchError((err) => { + expect(err.errorCode).toBe(ERRORS.userUnknown); + 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() }); @@ -658,7 +886,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 +898,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 +910,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 +933,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..192b958 100644 --- a/src/app/services/backend.service.ts +++ b/src/app/services/backend.service.ts @@ -15,6 +15,8 @@ import { Blip } from '../models/blip'; 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; @@ -152,6 +154,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; @@ -240,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; @@ -289,6 +305,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; @@ -313,6 +347,50 @@ export class BackendService { ); } + authenticateForVotingEvent( + user: string, + pwd: string, + votingEventId: string, + flowStepName: string + ): Observable<{ token: string; pwdInserted: boolean }> { + const payload = this.buildPostPayloadForService(ServiceNames.authenticateForVotingEvent); + payload['user'] = user; + payload['pwd'] = pwd; + payload['votingEventId'] = votingEventId; + payload['flowStepName'] = flowStepName; + 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 b6bc224..90fe331 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, @@ -16,6 +17,8 @@ export enum ServiceNames { closeVotingEvent, cancelVotingEvent, addNewTechnologyToEvent, + addCommentToTech, + addReplyToTechComment, getVoters, calculateBlips, calculateBlipsFromAllEvents, @@ -23,5 +26,8 @@ export enum ServiceNames { closeForRevote, getConfiguration, authenticate, + authenticateForVotingEvent, + addUsersWithRole, + deleteUsers, saveLogInfo } 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..afce2f9 --- /dev/null +++ b/src/app/utils/voting-event-flow.util.ts @@ -0,0 +1,50 @@ +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 = 'nickname'; + } else { + throw new Error(`No route defined for identification type ${identificationType}`); + } + return route; +} + +export function getAction(votingEvent: VotingEvent) { + return getFlowStep(votingEvent).action; +} + +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]; +}