From 9e4c454b5f9f334aa362346401c9ca8db29b85be Mon Sep 17 00:00:00 2001 From: Daniel Jancar Date: Sun, 11 Aug 2024 11:39:21 +0200 Subject: [PATCH 1/3] feat: create results page --- apps/frontend/src/app/app.routes.ts | 2 +- .../features/voting/cast/cast.component.ts | 2 +- .../voting/create/create.component.html | 8 +- .../voting/create/create.component.ts | 2 +- .../voting/results/results.component.html | 121 ++++++++++++---- .../voting/results/results.component.ts | 132 +++++++++++++++++- .../voting/thanks/thanks.component.css | 6 - .../voting/thanks/thanks.component.html | 48 ++----- .../voting/thanks/thanks.component.ts | 9 +- .../app/features/voting/voting.component.html | 2 +- .../app/features/voting/voting.component.ts | 18 --- .../app/utils/create-vote-validators.util.ts | 2 +- 12 files changed, 240 insertions(+), 112 deletions(-) diff --git a/apps/frontend/src/app/app.routes.ts b/apps/frontend/src/app/app.routes.ts index cdb52f6..9923396 100644 --- a/apps/frontend/src/app/app.routes.ts +++ b/apps/frontend/src/app/app.routes.ts @@ -44,7 +44,7 @@ export const appRoutes: Route[] = [ canDeactivate: [UnsavedChangesGuard], }, { - path: 'results/:id', + path: 'r/:id', component: ResultsComponent, }, { diff --git a/apps/frontend/src/app/features/voting/cast/cast.component.ts b/apps/frontend/src/app/features/voting/cast/cast.component.ts index 4a0aacc..f26f0a7 100644 --- a/apps/frontend/src/app/features/voting/cast/cast.component.ts +++ b/apps/frontend/src/app/features/voting/cast/cast.component.ts @@ -107,7 +107,7 @@ export class CastComponent implements OnChanges { return } } - this.router.navigate(['/voting/results', this.voteId]) + this.router.navigate(['/voting/r', this.voteId]) } public errorAction(): void { diff --git a/apps/frontend/src/app/features/voting/create/create.component.html b/apps/frontend/src/app/features/voting/create/create.component.html index 21bbd6a..d3aa225 100644 --- a/apps/frontend/src/app/features/voting/create/create.component.html +++ b/apps/frontend/src/app/features/voting/create/create.component.html @@ -140,18 +140,18 @@ @for (option of options.controls; track $index) {
- @if (voteForm.value.options[$index].option.length > 45) { + @if (voteForm.value.options[$index].option.length > 30) { - {{ voteForm.value.options[$index].option.length }}/50 + {{ voteForm.value.options[$index].option.length }}/32 }
diff --git a/apps/frontend/src/app/features/voting/create/create.component.ts b/apps/frontend/src/app/features/voting/create/create.component.ts index 81f7fff..742e180 100644 --- a/apps/frontend/src/app/features/voting/create/create.component.ts +++ b/apps/frontend/src/app/features/voting/create/create.component.ts @@ -80,7 +80,7 @@ export class CreateComponent Validators.minLength(2), Validators.maxLength(5), noDuplicateInOptions(), - maxOptionLengthValidator(50), + maxOptionLengthValidator(32), ], ), }, diff --git a/apps/frontend/src/app/features/voting/results/results.component.html b/apps/frontend/src/app/features/voting/results/results.component.html index b550640..0514653 100644 --- a/apps/frontend/src/app/features/voting/results/results.component.html +++ b/apps/frontend/src/app/features/voting/results/results.component.html @@ -1,33 +1,96 @@ -
-
-
-
-

- {{ dataArr[0] }} - Results -

+@if (hasError) { + +} @if (isLoading) { + +} @if (!isLoading && !hasError) { +
+
+

+ Voting Results +

+ +
+

+ {{ dataArr[0] }} +

+

+ {{ dataArr[1] }} +

+
+ + - - - } - +
+

Total votes: {{ totalVotes }}

+
+ + + +
+
-
+
+} diff --git a/apps/frontend/src/app/features/voting/results/results.component.ts b/apps/frontend/src/app/features/voting/results/results.component.ts index 0f7e33c..5447cf6 100644 --- a/apps/frontend/src/app/features/voting/results/results.component.ts +++ b/apps/frontend/src/app/features/voting/results/results.component.ts @@ -1,14 +1,132 @@ -import { Component, Input } from '@angular/core' +import { Component, OnDestroy, OnInit } from '@angular/core' +import { ActivatedRoute, Router } from '@angular/router' +import { SorobanRpc } from '@stellar/stellar-sdk' +import { Keypair } from '@stellar/typescript-wallet-sdk' +import { GetVoteResultsService } from '../../../core/stellar/getVoteResults.service' +import { VoteConfigService } from '../../../core/vote-transaction.service' +import { ErrorComponent } from '../../../shared/feedback/error/error.component' +import { LoadingComponent } from '../../../shared/feedback/loading/loading.component' +import { GetVoteService } from '../../../core/stellar/getVote.service' +import { Subscription } from 'rxjs' @Component({ selector: 'app-results', standalone: true, - imports: [], templateUrl: './results.component.html', - styleUrl: './results.component.css', + styleUrls: ['./results.component.css'], + imports: [ErrorComponent, LoadingComponent], }) -export class ResultsComponent { - @Input({ required: true }) voteId = '' - @Input({ required: true }) dataArr: Array = [] - @Input({ required: true }) resultArr: Array<{ key: string; val: string }> = [] +export class ResultsComponent implements OnInit, OnDestroy { + public isLoading = true + public hasError = false + public errorMessage = '' + public voteId = '' + public dataArr: Array = [] + public resultArr: Array<{ key: string; val: string }> = [] + public totalVotes = 0 + protected readonly parseInt = parseInt + private sourceKeypair!: Keypair + private server!: SorobanRpc.Server + private routeSubscription!: Subscription + + constructor( + private route: ActivatedRoute, + private router: Router, + private getVoteResultsService: GetVoteResultsService, + private getVoteService: GetVoteService, + private voteConfigService: VoteConfigService, + ) {} + + public async ngOnInit(): Promise { + try { + const config = await this.voteConfigService.getBaseVoteConfig() + this.server = config.server + this.sourceKeypair = config.sourceKeypair + + this.routeSubscription = this.route.params.subscribe(async params => { + this.voteId = params['id'] + this.isLoading = true + this.hasError = false + await this.loadVoteData() + }) + } catch (error) { + this.handleError('Failed to initialize results component.', error) + } + } + + public exportToCSV(): void { + const headers = ['Option', 'Votes'] + const rows = this.resultArr.map(result => [result.key, result.val]) + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.join(',')), + ].join('\n') + + const blob = new Blob([csvContent], { type: 'text/csv' }) + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `vote_results_${this.voteId}.csv` + a.click() + window.URL.revokeObjectURL(url) + } + + ngOnDestroy(): void { + if (this.routeSubscription) { + this.routeSubscription.unsubscribe() + } + } + + private async loadVoteData(): Promise { + try { + await this.getVoteData() + await this.getVoteResults() + } catch (error) { + this.handleError('Failed to load vote data.', error) + } finally { + this.isLoading = false + } + } + + private async getVoteData(): Promise { + const result = await this.getVoteService.getVote( + this.server, + this.sourceKeypair, + this.voteId, + ) + if (result?.hasError) { + throw new Error(result.errorMessage) + } + if (result) { + this.dataArr = result.dataArr + } + } + + private async getVoteResults(): Promise { + const result = await this.getVoteResultsService.getVoteResults( + this.server, + this.sourceKeypair, + this.voteId, + ) + + if (result?.hasError) { + throw new Error(result.errorMessage) + } + + if (result) { + this.resultArr = result.dataArr + this.totalVotes = this.resultArr.reduce( + (sum, option) => sum + parseInt(option.val, 10), + 0, + ) + } + } + + private handleError(message: string, error: any): void { + console.error(message, error) + this.hasError = true + this.errorMessage = message + this.isLoading = false + } } diff --git a/apps/frontend/src/app/features/voting/thanks/thanks.component.css b/apps/frontend/src/app/features/voting/thanks/thanks.component.css index 0161af9..e69de29 100644 --- a/apps/frontend/src/app/features/voting/thanks/thanks.component.css +++ b/apps/frontend/src/app/features/voting/thanks/thanks.component.css @@ -1,6 +0,0 @@ -.fixed-center { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} diff --git a/apps/frontend/src/app/features/voting/thanks/thanks.component.html b/apps/frontend/src/app/features/voting/thanks/thanks.component.html index 1073439..8788d59 100644 --- a/apps/frontend/src/app/features/voting/thanks/thanks.component.html +++ b/apps/frontend/src/app/features/voting/thanks/thanks.component.html @@ -1,40 +1,8 @@ -
-
- - - -

Thank You !

-

Thank you for voting on this poll!

- - - - - Home - -
-
+ diff --git a/apps/frontend/src/app/features/voting/thanks/thanks.component.ts b/apps/frontend/src/app/features/voting/thanks/thanks.component.ts index 4fb5343..3dec456 100644 --- a/apps/frontend/src/app/features/voting/thanks/thanks.component.ts +++ b/apps/frontend/src/app/features/voting/thanks/thanks.component.ts @@ -1,10 +1,13 @@ -import { Component } from '@angular/core' +import { Component, Input } from '@angular/core' +import { SuccessComponent } from '../../../shared/feedback/success/success.component' @Component({ selector: 'app-thanks', standalone: true, - imports: [], + imports: [SuccessComponent], templateUrl: './thanks.component.html', styleUrl: './thanks.component.css', }) -export class ThanksComponent {} +export class ThanksComponent { + @Input({ required: true }) voteId = '' +} diff --git a/apps/frontend/src/app/features/voting/voting.component.html b/apps/frontend/src/app/features/voting/voting.component.html index eed13ce..8cef8d1 100644 --- a/apps/frontend/src/app/features/voting/voting.component.html +++ b/apps/frontend/src/app/features/voting/voting.component.html @@ -1,5 +1,5 @@ @if (hasAlreadyVoted) { - + } @else { @if (isLoading) { } @if (!isLoading && !hasError) { diff --git a/apps/frontend/src/app/features/voting/voting.component.ts b/apps/frontend/src/app/features/voting/voting.component.ts index 28f798a..08becb4 100644 --- a/apps/frontend/src/app/features/voting/voting.component.ts +++ b/apps/frontend/src/app/features/voting/voting.component.ts @@ -88,7 +88,6 @@ export class VotingComponent implements OnInit, OnDestroy { await this.getVoteData() await this.checkIfUserHasVoted() await this.getVoteOptions() - await this.getVoteResults() this.isLoading = false } @@ -126,23 +125,6 @@ export class VotingComponent implements OnInit, OnDestroy { } } - private async getVoteResults(): Promise { - const result = await this.getVoteResultsService.getVoteResults( - this.server, - this.sourceKeypair, - this.voteId, - ) - if (result?.hasError) { - this.hasError = true - this.errorMessage = result.errorMessage - this.isLoading = false - return - } - if (result) { - this.resultArr = result.dataArr - } - } - private async checkIfUserHasVoted(): Promise { const result = await this.checkUserVotedService.checkIfUserVoted( this.server, diff --git a/apps/frontend/src/app/utils/create-vote-validators.util.ts b/apps/frontend/src/app/utils/create-vote-validators.util.ts index 6aa8b7a..c89d171 100644 --- a/apps/frontend/src/app/utils/create-vote-validators.util.ts +++ b/apps/frontend/src/app/utils/create-vote-validators.util.ts @@ -16,7 +16,7 @@ export function noDuplicateInOptions(): ValidatorFn { } } -export function maxOptionLengthValidator(maxLength: number = 32): ValidatorFn { +export function maxOptionLengthValidator(maxLength = 32): ValidatorFn { return (control: AbstractControl): ValidationErrors | null => { const formArray = control as FormArray const hasLongOption = formArray.value.some( From 5619f2939412fb8d1e9f788721f10f7d9274e781 Mon Sep 17 00:00:00 2001 From: Daniel Jancar Date: Sun, 11 Aug 2024 11:45:15 +0200 Subject: [PATCH 2/3] fix: change option description to new max lenght --- .../src/app/features/voting/create/create.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/app/features/voting/create/create.component.html b/apps/frontend/src/app/features/voting/create/create.component.html index d3aa225..2c30e14 100644 --- a/apps/frontend/src/app/features/voting/create/create.component.html +++ b/apps/frontend/src/app/features/voting/create/create.component.html @@ -198,7 +198,7 @@ } @if (voteForm.hasError('maxOptionLength', ['options'])) {

- Each option must be less than 50 characters. + Each option must be less than 32 characters.

} @if (options.controls.length < 5) { From 3d4e035d2d7778baa7fc32cbbb1b8c374faf8148 Mon Sep 17 00:00:00 2001 From: Daniel Jancar Date: Sun, 11 Aug 2024 12:37:50 +0200 Subject: [PATCH 3/3] feat: add cast vote banner to results if user hasn't voted yet --- .../voting/results/results.component.html | 35 +++++++++++++++++-- .../voting/results/results.component.ts | 21 +++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/app/features/voting/results/results.component.html b/apps/frontend/src/app/features/voting/results/results.component.html index 0514653..426d1a0 100644 --- a/apps/frontend/src/app/features/voting/results/results.component.html +++ b/apps/frontend/src/app/features/voting/results/results.component.html @@ -37,6 +37,37 @@ + @if (!hasAlreadyVoted) { +
+ +
+ } + + +
@for (result of resultArr; track $index) {
@@ -75,14 +106,14 @@ class="bg-gray-100 border border-gray-400 text-gray-900 px-4 py-3 rounded" role="alert" > -

Export Voting Data

+

Export Voting Data

You can export the voting results to a CSV file for further analysis or record-keeping.