Skip to content

Commit

Permalink
feat: create results page (#47)
Browse files Browse the repository at this point in the history
* feat: create results page

* fix: change option description to new max lenght

* feat: add cast vote banner to results if user hasn't voted yet
  • Loading branch information
danieljancar authored Aug 11, 2024
1 parent 7f4a747 commit b84ee98
Show file tree
Hide file tree
Showing 11 changed files with 292 additions and 112 deletions.
2 changes: 1 addition & 1 deletion apps/frontend/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export const appRoutes: Route[] = [
canDeactivate: [UnsavedChangesGuard],
},
{
path: 'results/:id',
path: 'r/:id',
component: ResultsComponent,
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,18 +150,18 @@
@for (option of options.controls; track $index) {
<div [formGroupName]="$index" class="space-y-2">
<div class="flex justify-end">
@if (voteForm.value.options[$index].option.length > 45) {
@if (voteForm.value.options[$index].option.length > 30) {
<span
[ngClass]="{
'text-gray-500':
voteForm.value.options[$index].option.length <= 45,
voteForm.value.options[$index].option.length <= 30,
'text-red-600':
voteForm.value.options[$index].option.length > 50
voteForm.value.options[$index].option.length > 32
}"
class="text-sm leading-6 text-gray-500"
id="{{ $index }}-count"
>
{{ voteForm.value.options[$index].option.length }}/50
{{ voteForm.value.options[$index].option.length }}/32
</span>
}
</div>
Expand Down Expand Up @@ -208,7 +208,7 @@
} @if (voteForm.hasError('maxOptionLength', ['options'])) {
<div>
<p class="mt-2 text-sm text-red-600" id="options-length-error">
Each option must be less than 50 characters.
Each option must be less than 32 characters.
</p>
</div>
} @if (options.controls.length < 5) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export class CreateComponent
Validators.minLength(2),
Validators.maxLength(5),
noDuplicateInOptions(),
maxOptionLengthValidator(50),
maxOptionLengthValidator(32),
],
),
},
Expand Down
152 changes: 123 additions & 29 deletions apps/frontend/src/app/features/voting/results/results.component.html
Original file line number Diff line number Diff line change
@@ -1,33 +1,127 @@
<div class="flex justify-center mt-20">
<fieldset class="w-11/12">
<div class="overflow-hidden bg-white sm:rounded-lg sm:shadow">
<div class="border-b border-gray-200 bg-white px-4 py-5 sm:px-6">
<h3 class="font-semibold leading-6 text-gray-900 text-xl">
{{ dataArr[0] }} - Results
</h3>
@if (hasError) {
<app-error
[title]="'Error occurred'"
[message]="errorMessage"
[action]="'Go to Home'"
[route]="'/'"
aria-live="assertive"
aria-label="Error occurred"
></app-error>
} @if (isLoading) {
<app-loading aria-live="polite" aria-label="Loading results..."></app-loading>
} @if (!isLoading && !hasError) {
<div class="mx-auto max-w-7xl" role="region" aria-labelledby="results-title">
<div class="mx-1 sm:mx-1.5 md:mx-3 lg:mx-5 xl:mx-0">
<h1
id="results-title"
class="text-2xl font-semibold leading-7 text-gray-900"
>
Voting Results
</h1>

<div class="py-3">
<h2
id="vote-question"
class="text-xl font-semibold leading-7 text-gray-900"
>
{{ dataArr[0] }}
</h2>
<p id="vote-description" class="mt-1 text-sm leading-6 text-gray-600">
{{ dataArr[1] }}
</p>
</div>

<div class="relative mt-6" role="separator" aria-orientation="horizontal">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t border-gray-300"></div>
</div>
<ul role="list" class="divide-y divide-gray-200">
@for (result of resultArr; track result.key){
<li>
<a href="#" class="block hover:bg-gray-50">
<div class="px-4 py-4 sm:px-6">
<div class="flex cursor-pointer justify-between items-center">
<div>
<p class="text-gray-950 text-xl">{{ result.key }}</p>
</div>

<div class="ml-2 flex flex-shrink-0">
<span
class="w-12 h-12 inline-flex justify-center items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20"
>{{ result.val }}</span
>
</div>
</div>
</div>
</div>

@if (!hasAlreadyVoted) {
<div class="pt-6">
<div
aria-live="assertive"
class="bg-gray-100 border border-gray-400 text-gray-900 px-4 py-3 rounded"
role="alert"
>
<h2 class="font-bold">Your opinion?</h2>
<p class="text-sm">
It looks like you didn't cast your vote yet. Give your opinion on this
vote - every opinion matters!
<a
[href]="'/voting/' + voteId"
class="hover:underline font-medium cursor-pointer text-gray-900 hover:text-gray-600 focus:outline-none focus:underline focus:text-gray-600"
role="link"
tabindex="0"
aria-label="Cast your vote"
>
Cast your vote
</a>
</li>
}
</ul>
</p>
</div>
</div>
}

<div class="relative mt-6" role="separator" aria-orientation="horizontal">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t border-gray-300"></div>
</div>
</div>

<div class="pt-6 space-y-4" role="list" aria-labelledby="results-title">
@for (result of resultArr; track $index) {
<div class="relative bg-gray-100 rounded-lg p-4" role="listitem">
<div class="flex justify-between">
<p id="option-{{ $index }}" class="text-gray-700 text-lg break-words">
{{ result.key }}
</p>
<p id="votes-{{ $index }}" class="text-gray-500">
{{ result.val }} votes
</p>
</div>
<div class="mt-2 h-4 bg-gray-300 rounded-lg">
<div
class="h-4 rounded-lg bg-indigo-600"
role="progressbar"
[style.width]="(parseInt(result.val, 10) / totalVotes) * 100 + '%'"
></div>
</div>
</div>
}
</div>

<div class="mt-3 text-center text-sm text-gray-500">
<p>Total votes: {{ totalVotes }}</p>
</div>

<div class="relative mt-6" role="separator" aria-orientation="horizontal">
<div class="absolute inset-0 flex items-center" aria-hidden="true">
<div class="w-full border-t border-gray-300"></div>
</div>
</div>

<div class="pt-6">
<div
aria-live="assertive"
class="bg-gray-100 border border-gray-400 text-gray-900 px-4 py-3 rounded"
role="alert"
>
<h2 class="font-bold">Export Voting Data</h2>
<p class="text-sm">
You can export the voting results to a CSV file for further analysis
or record-keeping.
<button
(click)="exportToCSV()"
class="hover:underline font-medium cursor-pointer text-gray-900 hover:text-gray-600 focus:outline-none focus:underline focus:text-gray-600"
role="link"
tabindex="0"
aria-label="Export results"
>
Export Results
</button>
</p>
</div>
</div>
</fieldset>
</div>
</div>
}
153 changes: 146 additions & 7 deletions apps/frontend/src/app/features/voting/results/results.component.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,153 @@
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'
import { CheckUserVotedService } from '../../../core/stellar/checkUserVoted.service'

@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<string> = []
@Input({ required: true }) resultArr: Array<{ key: string; val: string }> = []
export class ResultsComponent implements OnInit, OnDestroy {
public hasAlreadyVoted = false
public isLoading = true
public hasError = false
public errorMessage = ''
public voteId = ''
public dataArr: Array<string> = []
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,
private checkUserVotedService: CheckUserVotedService,
) {}

public async ngOnInit(): Promise<void> {
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<void> {
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<void> {
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<void> {
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 async checkIfUserHasVoted(): Promise<void> {
const result = await this.checkUserVotedService.checkIfUserVoted(
this.server,
this.sourceKeypair,
this.voteId,
this.sourceKeypair.publicKey(),
)
if (result?.hasError) {
this.hasError = true
this.errorMessage = result.errorMessage
this.isLoading = false
return
}
if (result) {
this.hasAlreadyVoted = result.hasVoted
}
}

private handleError(message: string, error: any): void {
console.error(message, error)
this.hasError = true
this.errorMessage = message
this.isLoading = false
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +0,0 @@
.fixed-center {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
Loading

0 comments on commit b84ee98

Please sign in to comment.