Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create results page #47

Merged
merged 3 commits into from
Aug 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -140,18 +140,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 @@ -198,7 +198,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