From f939ede852d4362a27db8d882859b8dce7bf1cb0 Mon Sep 17 00:00:00 2001 From: Enrico PIccinin Date: Wed, 3 Jul 2019 19:40:02 +0200 Subject: [PATCH] feat(nickname.component): nickname component introduced and feature toggle for voting event flow It is possible to enable the voting event flow setting the property "enableVotingEventFlow" to true (boolean) in the default configuration defined in the mongo db --- src/app/app-routes.ts | 5 + src/app/app-session.service.ts | 9 ++ src/app/app.component.ts | 51 ++++++--- src/app/models/credentials.ts | 4 + src/app/modules/login/login.module.ts | 3 +- .../login/nickname/nickname.component.html | 26 +++++ .../login/nickname/nickname.component.scss | 53 +++++++++ .../login/nickname/nickname.component.spec.ts | 44 +++++++ .../login/nickname/nickname.component.ts | 108 ++++++++++++++++++ .../start-voting-session.component.ts | 5 +- src/app/modules/vote/vote/vote.component.ts | 33 ++++-- src/app/utils/voting-event-flow.util.ts | 2 +- 12 files changed, 312 insertions(+), 31 deletions(-) create mode 100644 src/app/models/credentials.ts create mode 100644 src/app/modules/login/nickname/nickname.component.html create mode 100644 src/app/modules/login/nickname/nickname.component.scss create mode 100644 src/app/modules/login/nickname/nickname.component.spec.ts create mode 100644 src/app/modules/login/nickname/nickname.component.ts diff --git a/src/app/app-routes.ts b/src/app/app-routes.ts index e5986e5..300136b 100644 --- a/src/app/app-routes.ts +++ b/src/app/app-routes.ts @@ -3,6 +3,7 @@ import { LoginComponent } from './modules/login/login.component'; import { ErrorComponent } from './components/error/error.component'; import { VotingEventSelectComponent } from './components/voting-event-select/voting-event-select.component'; import { LoginVotingEventComponent } from './modules/login/login-voting-event/login-voting-event.component'; +import { NicknameComponent } from './modules/login/nickname/nickname.component'; export const appRoutes: Routes = [ { @@ -26,6 +27,10 @@ export const appRoutes: Routes = [ path: 'login-voting-event', component: LoginVotingEventComponent }, + { + path: 'nickname', + component: NicknameComponent + }, { path: 'vote', loadChildren: './modules/vote/vote.module#VoteModule' diff --git a/src/app/app-session.service.ts b/src/app/app-session.service.ts index 827442c..b464b5d 100644 --- a/src/app/app-session.service.ts +++ b/src/app/app-session.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@angular/core'; import { VotingEvent } from 'src/app/models/voting-event'; import { Technology } from './models/technology'; +import { Credentials } from './models/credentials'; @Injectable({ providedIn: 'root' @@ -9,6 +10,7 @@ export class AppSessionService { private votingEvents: VotingEvent[]; private selectedVotingEvent: VotingEvent; private selectedTechnology: Technology; + private credentials: Credentials; constructor() {} @@ -32,4 +34,11 @@ export class AppSessionService { setSelectedTechnology(technology: Technology) { this.selectedTechnology = technology; } + + getCredentials() { + return this.credentials; + } + setCredentials(credentials: Credentials) { + this.credentials = credentials; + } } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 6876b4a..5f11298 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -5,7 +5,8 @@ import { BackendService } from './services/backend.service'; import { ErrorService } from './services/error.service'; import { AppSessionService } from './app-session.service'; import { getIdentificationRoute } from './utils/voting-event-flow.util'; -import { map } from 'rxjs/operators'; +import { map, tap } from 'rxjs/operators'; +import { ConfigurationService } from './services/configuration.service'; @Component({ selector: 'byor-root', @@ -19,27 +20,39 @@ export class AppComponent implements OnInit { private router: Router, private backend: BackendService, public errorService: ErrorService, - private appSession: AppSessionService + private appSession: AppSessionService, + private configurationService: ConfigurationService ) {} ngOnInit() { - this.backend - .getVotingEvents() - .pipe(map((votingEvents) => votingEvents.filter((ve) => ve.status === 'open'))) - .subscribe((votingEvents) => { - if (!votingEvents || votingEvents.length === 0) { - this.errorService.setError(new Error('There are no Voting Events open')); - this.router.navigate(['error']); - } else if (votingEvents.length === 1) { - const votingEvent = votingEvents[0]; - this.appSession.setSelectedVotingEvent(votingEvent); - const route = getIdentificationRoute(votingEvent); - this.router.navigate([route]); - } else { - this.appSession.setVotingEvents(votingEvents); - this.router.navigate(['selectVotingEvent']); - } - }); + this.configurationService + .defaultConfiguration() + .pipe( + tap((config) => { + if (config.enableVotingEventFlow) { + this.backend + .getVotingEvents() + .pipe(map((votingEvents) => votingEvents.filter((ve) => ve.status === 'open'))) + .subscribe((votingEvents) => { + if (!votingEvents || votingEvents.length === 0) { + this.errorService.setError(new Error('There are no Voting Events open')); + this.router.navigate(['error']); + } else if (votingEvents.length === 1) { + const votingEvent = votingEvents[0]; + this.appSession.setSelectedVotingEvent(votingEvent); + const route = getIdentificationRoute(votingEvent); + this.router.navigate([route]); + } else { + this.appSession.setVotingEvents(votingEvents); + this.router.navigate(['selectVotingEvent']); + } + }); + } else { + this.router.navigate(['vote']); + } + }) + ) + .subscribe(); } goToAdminPage() { diff --git a/src/app/models/credentials.ts b/src/app/models/credentials.ts new file mode 100644 index 0000000..5056be8 --- /dev/null +++ b/src/app/models/credentials.ts @@ -0,0 +1,4 @@ +export interface Credentials { + userId?: string; + nickname?: string; +} diff --git a/src/app/modules/login/login.module.ts b/src/app/modules/login/login.module.ts index 997d0d9..e451f64 100644 --- a/src/app/modules/login/login.module.ts +++ b/src/app/modules/login/login.module.ts @@ -5,9 +5,10 @@ import { AuthGuard } from './auth.guard'; import { AuthService } from './auth.service'; import { AppMaterialModule } from '../../app-material.module'; import { LoginVotingEventComponent } from './login-voting-event/login-voting-event.component'; +import { NicknameComponent } from './nickname/nickname.component'; @NgModule({ - declarations: [LoginComponent, LoginVotingEventComponent], + declarations: [LoginComponent, LoginVotingEventComponent, NicknameComponent], providers: [AuthGuard, AuthService], imports: [CommonModule, AppMaterialModule], exports: [LoginComponent] diff --git a/src/app/modules/login/nickname/nickname.component.html b/src/app/modules/login/nickname/nickname.component.html new file mode 100644 index 0000000..317c12a --- /dev/null +++ b/src/app/modules/login/nickname/nickname.component.html @@ -0,0 +1,26 @@ +
+
+ Voting for event: {{appSession.getSelectedVotingEvent().name}} +
+
+ +
+
+ + +
+ +
+ +
+ +
+ +
+ \ No newline at end of file diff --git a/src/app/modules/login/nickname/nickname.component.scss b/src/app/modules/login/nickname/nickname.component.scss new file mode 100644 index 0000000..ece851c --- /dev/null +++ b/src/app/modules/login/nickname/nickname.component.scss @@ -0,0 +1,53 @@ +@import "../../../styles/abstracts/mixins"; + +$login-sections-max-width: 480px; + +.nickname-section { + margin: 1.3rem auto; + padding: 0 1.3rem; + max-width: $login-sections-max-width; + + .event-selection { + display: block; + } + + mat-form-field { + width: 100%; + } + + .nickname { + margin-bottom: 2rem; + + .nickname-input { + width: 100%; + @include byor-input; + } + + label { + display: block; + margin-bottom: .3rem; + } + } + + .button-disabled { + border: none; + } + + .button-text { + color: #ffffff; + } +} + +.banner-section { + margin: 0 auto; + padding: 6rem 1.3rem; + max-width: $login-sections-max-width; + text-align: center; + + a { + border-bottom: none; + img { + max-width: 100%; + } + } +} \ No newline at end of file diff --git a/src/app/modules/login/nickname/nickname.component.spec.ts b/src/app/modules/login/nickname/nickname.component.spec.ts new file mode 100644 index 0000000..c978ae4 --- /dev/null +++ b/src/app/modules/login/nickname/nickname.component.spec.ts @@ -0,0 +1,44 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NicknameComponent } from './nickname.component'; +import { RouterTestingModule } from '@angular/router/testing'; +import { AppMaterialModule } from 'src/app/app-material.module'; +import { HttpClientModule } from '@angular/common/http'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { VotingEvent } from 'src/app/models/voting-event'; +import { AppSessionService } from 'src/app/app-session.service'; + +class MockAppSessionService { + private votingEvent: VotingEvent; + + constructor() { + this.votingEvent = { _id: '123', name: 'an event', status: 'open', creationTS: 'abc' }; + } + + getSelectedVotingEvent() { + return this.votingEvent; + } +} + +describe('NicknameComponent', () => { + let component: NicknameComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [NicknameComponent], + imports: [RouterTestingModule, AppMaterialModule, HttpClientModule, BrowserAnimationsModule], + providers: [{ provide: AppSessionService, useClass: MockAppSessionService }] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(NicknameComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/modules/login/nickname/nickname.component.ts b/src/app/modules/login/nickname/nickname.component.ts new file mode 100644 index 0000000..25ad127 --- /dev/null +++ b/src/app/modules/login/nickname/nickname.component.ts @@ -0,0 +1,108 @@ +import { Component, OnInit, AfterViewInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; +import { Router } from '@angular/router'; + +import { Observable, Subject, fromEvent, Subscription, NEVER } from 'rxjs'; +import { shareReplay, map, share, switchMap, tap, filter } from 'rxjs/operators'; + +import { AppSessionService } from 'src/app/app-session.service'; +import { ConfigurationService } from 'src/app/services/configuration.service'; +import { BackendService } from 'src/app/services/backend.service'; +import { VoteCredentials } from 'src/app/models/vote-credentials'; +import { ErrorService } from 'src/app/services/error.service'; +import { logError } from 'src/app/utils/utils'; + +@Component({ + selector: 'byor-nickname', + templateUrl: './nickname.component.html', + styleUrls: ['./nickname.component.scss'] +}) +export class NicknameComponent implements AfterViewInit, OnDestroy, OnInit { + constructor( + private router: Router, + private errorService: ErrorService, + public appSession: AppSessionService, + private configurationService: ConfigurationService, + private backend: BackendService + ) {} + + isValidInputData$: Observable; + message$ = new Subject(); + configuration$: Observable; + + goToVoteSubscription: Subscription; + + @ViewChild('nickname') voterFirstName: ElementRef; + @ViewChild('startButton', { read: ElementRef }) startButtonRef: ElementRef; + + ngOnInit() { + this.configuration$ = this.configurationService.defaultConfiguration().pipe(shareReplay(1)); + } + + ngAfterViewInit() { + // notify when nickname is changed + const nickname$ = fromEvent(this.voterFirstName.nativeElement, 'keyup').pipe(map(() => this.voterFirstName.nativeElement.value)); + + const startButtonClick$ = fromEvent(this.startButtonRef.nativeElement, 'click'); + + // the main subscription + this.goToVoteSubscription = this.goToVote$(nickname$, startButtonClick$).subscribe( + (nickname) => { + this.appSession.setCredentials({ nickname }); + this.router.navigate(['vote/start']); + }, + (error) => { + logError(error); + let _errMsg = error; + if (error.message) { + _errMsg = error.message; + } + this.errorService.setError(_errMsg); + this.errorService.setErrorMessage(error); + this.router.navigate(['error']); + } + ); + } + + ngOnDestroy() { + if (this.goToVoteSubscription) { + this.goToVoteSubscription.unsubscribe(); + } + } + + // this method takes in input all Observable created out of DOM events which are relevant for this Component + // in this way we can easily test this logic with marble tests + goToVote$(nickname$: Observable, startButtonClick$: Observable) { + // notifies when the input data provided changes - the value notified is true of false + // depending on the fact that the input data is valid or not + this.isValidInputData$ = nickname$.pipe( + map((nickname) => this.isNicknameValid(nickname)), + share() // share() is used since this Observable is used also on the Html template + ); + + const clickOnVote$ = nickname$.pipe( + switchMap((nickname) => (this.isNicknameValid(nickname) ? startButtonClick$.pipe(map(() => nickname)) : NEVER)) + ); + + // notifies when the user has clicked to go to voting session and he has not voted yet + return clickOnVote$.pipe( + switchMap((nickname) => { + const votingEvent = this.appSession.getSelectedVotingEvent(); + // to remove when we refactor hasAlreadyVoted + const oldCredentials: VoteCredentials = { voterId: { firstName: nickname, lastName: '' }, votingEvent }; + return this.backend.hasAlreadyVoted(oldCredentials).pipe( + tap((hasAlreadyVoted) => { + if (hasAlreadyVoted) { + this.message$.next(`You have already voted for ${votingEvent.name}`); + } + }), + filter((hasAlreadyVoted) => !hasAlreadyVoted), + map(() => nickname) + ); + }) + ); + } + + isNicknameValid(nickname: string) { + return nickname && nickname.trim().length > 0; + } +} diff --git a/src/app/modules/vote/start-voting-session/start-voting-session.component.ts b/src/app/modules/vote/start-voting-session/start-voting-session.component.ts index c884af8..9f59e4f 100644 --- a/src/app/modules/vote/start-voting-session/start-voting-session.component.ts +++ b/src/app/modules/vote/start-voting-session/start-voting-session.component.ts @@ -8,6 +8,7 @@ import { VoteService } from '../services/vote.service'; import { ErrorService } from 'src/app/services/error.service'; import { ConfigurationService } from 'src/app/services/configuration.service'; import { logError } from 'src/app/utils/utils'; +import { AppSessionService } from 'src/app/app-session.service'; @Component({ selector: 'byor-start-voting-session', @@ -34,7 +35,8 @@ export class StartVotingSessionComponent implements AfterViewInit, OnDestroy, On private router: Router, private voteService: VoteService, private errorService: ErrorService, - private configurationService: ConfigurationService + private configurationService: ConfigurationService, + private appSession: AppSessionService ) {} openVotingEvents$: Observable>; @@ -83,6 +85,7 @@ export class StartVotingSessionComponent implements AfterViewInit, OnDestroy, On .subscribe( (credentials) => { this.voteService.credentials = credentials; + this.appSession.setSelectedVotingEvent(credentials.votingEvent); this.router.navigate(['vote/start']); }, (error) => { diff --git a/src/app/modules/vote/vote/vote.component.ts b/src/app/modules/vote/vote/vote.component.ts index bd8fab2..fe2fcc5 100644 --- a/src/app/modules/vote/vote/vote.component.ts +++ b/src/app/modules/vote/vote/vote.component.ts @@ -23,6 +23,8 @@ import { Comment } from 'src/app/models/comment'; import { logError } from 'src/app/utils/utils'; import { AppSessionService } from 'src/app/app-session.service'; import { getActionName } from 'src/app/utils/voting-event-flow.util'; +import { ninvoke } from 'q'; +import { ConfigurationService } from 'src/app/services/configuration.service'; @Component({ selector: 'byor-vote', @@ -68,7 +70,8 @@ export class VoteComponent implements AfterViewInit, OnDestroy { private errorService: ErrorService, public dialog: MatDialog, private voteService: VoteService, - private appSession: AppSessionService + private appSession: AppSessionService, + private configurationService: ConfigurationService ) {} ngAfterViewInit() { @@ -112,7 +115,8 @@ export class VoteComponent implements AfterViewInit, OnDestroy { } getTechnologies() { - const votingEvent = this.appSession.getSelectedVotingEvent(); + // @todo remove "|| this.voteService.credentials.votingEvent" once the enableVotingEventFlow toggle is removed + const votingEvent = this.appSession.getSelectedVotingEvent() || this.voteService.credentials.votingEvent; return this.backEnd.getVotingEvent(votingEvent._id).pipe( map((event) => { let technologies = event.technologies; @@ -146,7 +150,7 @@ export class VoteComponent implements AfterViewInit, OnDestroy { } createNewTechnology(name: string, quadrant: string) { - const votingEvent = this.voteService.credentials.votingEvent; + const votingEvent = this.appSession.getSelectedVotingEvent(); const technology: Technology = { name: name, isnew: true, @@ -189,7 +193,7 @@ export class VoteComponent implements AfterViewInit, OnDestroy { } goToConversation(technology: Technology) { - this.voteService.technology = technology; + this.appSession.setSelectedTechnology(technology); this.router.navigate(['vote/conversation']); } @@ -205,12 +209,22 @@ export class VoteComponent implements AfterViewInit, OnDestroy { } saveVotes() { - this.backEnd.saveVote(this.votes, this.voteService.credentials).subscribe( - (resp) => { + const credentials = this.appSession.getCredentials(); + const votingEvent = this.appSession.getSelectedVotingEvent(); + let voterIdentification; + let oldCredentials: VoteCredentials; + if (credentials) { + voterIdentification = credentials.nickname || credentials.userId; + oldCredentials = { voterId: { firstName: voterIdentification, lastName: '' }, votingEvent }; + } else { + oldCredentials = this.voteService.credentials; + voterIdentification = oldCredentials.voterId.firstName + ' ' + oldCredentials.voterId.lastName; + } + combineLatest(this.backEnd.saveVote(this.votes, oldCredentials), this.configurationService.defaultConfiguration()).subscribe( + ([resp, config]) => { if (resp.error) { if (resp.error.errorCode === 'V-01') { - const voterName = this.getVoterFirstLastName(this.voteService.credentials); - this.messageVote = ` ${voterName} has already voted`; + this.messageVote = ` ${voterIdentification} has already voted`; } else { this.messageVote = `Vote could not be saved - look at the browser console - maybe there is something there`; @@ -221,7 +235,8 @@ export class VoteComponent implements AfterViewInit, OnDestroy { }); dialogRef.afterClosed().subscribe((result) => { - this.router.navigate(['/vote']); + const route = config.enableVotingEventFlow ? 'nickname' : '/vote'; + this.router.navigate([route]); }); } }, diff --git a/src/app/utils/voting-event-flow.util.ts b/src/app/utils/voting-event-flow.util.ts index 3801319..afce2f9 100644 --- a/src/app/utils/voting-event-flow.util.ts +++ b/src/app/utils/voting-event-flow.util.ts @@ -10,7 +10,7 @@ export function getIdentificationRoute(votingEvent: VotingEvent) { if (identificationType === 'login') { route = 'login-voting-event'; } else if (identificationType === 'nickname') { - route = 'vote'; + route = 'nickname'; } else { throw new Error(`No route defined for identification type ${identificationType}`); }