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}`); }