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: Use sidekick server to handle CSV report download #3835

Merged
merged 13 commits into from
Jun 16, 2023
Merged
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
VITE_HUB_URL=https://testnet.snapshot.org
VITE_RELAYER_URL=https://testnet.snapshot.org
VITE_SCORES_URL=https://score.snapshot.org
VITE_SIDEKICK_URL=https://sh5.co
VITE_IPFS_GATEWAY=snapshot.mypinata.cloud
VITE_DEFAULT_NETWORK=1
VITE_PUSHER_BEAMS_INSTANCE_ID=2e080021-d495-456d-b2cf-84f9fd718442
Expand Down
41 changes: 29 additions & 12 deletions src/components/SpaceProposalVotesList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,16 @@ const modalVotesmOpen = ref(false);

const voteCount = computed(() => props.proposal.votes);

const { downloadVotes, isDownloadingVotes, downloadProgress } =
useReportDownload();
const modalVotesDownloadOpen = ref(false);
const { downloadVotes, isDownloadingVotes, errorCode } = useReportDownload();

async function downloadReport(proposalId: string) {
const response = await downloadVotes(proposalId);

if (!response) {
modalVotesDownloadOpen.value = true;
}
}

onMounted(async () => {
await loadVotes();
Expand All @@ -38,19 +46,28 @@ onMounted(async () => {
>
<template v-if="props.proposal.state === 'closed'" #button>
<BaseButtonIcon
v-if="!isDownloadingVotes"
v-tippy="{ content: $t('proposal.downloadCsvVotes') }"
@click="downloadVotes(proposal.id, proposal.space.id)"
v-tippy="{ content: $t('proposal.downloadCsvVotes.title') }"
:loading="isDownloadingVotes"
@click="downloadReport(proposal.id)"
>
<i-ho-download />
</BaseButtonIcon>
<div v-else class="flex">
<LoadingSpinner v-if="downloadProgress < 1" :small="true" />
<template v-else>
{{ $t('proposal.preparingCsvVotes') }}…
<BaseProgressRadial class="my-1 ml-2" :value="downloadProgress" />
</template>
</div>
<ModalMessage
:open="modalVotesDownloadOpen"
:title="$t('proposal.downloadCsvVotes.postDownloadModal.title')"
:message="
$t(
`proposal.downloadCsvVotes.postDownloadModal.message.${
errorCode?.message === 'PENDING_GENERATION'
? 'pendingGeneration'
: 'unknownError'
}`
)
"
:level="'warning'"
@close="modalVotesDownloadOpen = false"
>
</ModalMessage>
</template>
<SpaceProposalVotesListItem
v-for="(vote, i) in sortedVotes"
Expand Down
132 changes: 39 additions & 93 deletions src/composables/useReportDownload.ts
Original file line number Diff line number Diff line change
@@ -1,108 +1,54 @@
import jsonexport from 'jsonexport/dist';
import pkg from '@/../package.json';
import { getProposalVotes, getProposal } from '@/helpers/snapshot';
import { Vote } from '@/helpers/interfaces';

export function useReportDownload() {
const isDownloadingVotes = ref(false);
const downloadProgress = ref(0);

async function getCsvFile(
data: any[] | Record<string, any>,
headers: string[],
fileName: string
) {
const csv = await jsonexport(data, { headers });
const link = document.createElement('a');
link.setAttribute('href', `data:text/csv;charset=utf-8,${csv}`);
link.setAttribute('download', `${fileName}.csv`);
document.body.appendChild(link);
link.click();
const errorCode: globalThis.Ref<null | Error> = ref(null);

async function downloadFile(blob: Blob, fileName: string) {
const href = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), {
href,
style: 'display:none',
download: fileName
});
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(href);
a.remove();
}

async function getAllVotes(
proposalId: string,
space: string,
totalVotesCount: number
) {
let votes: Vote[] = [];
let page = 0;
let createdPivot = 0;
const pageSize = 1000;
let resultsSize = 0;
const maxPage = 5;
do {
let newVotes = await getProposalVotes(proposalId, {
first: pageSize,
skip: page * pageSize,
space: space,
created_gte: createdPivot,
orderBy: 'created',
orderDirection: 'asc'
});
resultsSize = newVotes.length;

if (page === 0 && createdPivot > 0) {
const existingIpfs = votes.slice(-pageSize).map(vote => vote.ipfs);

newVotes = newVotes.filter(vote => {
return !existingIpfs.includes(vote.ipfs);
});
}
async function downloadVotes(proposalId: string) {
isDownloadingVotes.value = true;
errorCode.value = null;

if (page === maxPage) {
page = 0;
createdPivot = newVotes[newVotes.length - 1].created;
} else {
page++;
return fetch(
`${import.meta.env.VITE_SIDEKICK_URL}/api/votes/${proposalId}`,
{
method: 'POST'
}

votes = [...votes, ...newVotes];
downloadProgress.value = Math.floor(
(votes.length / totalVotesCount) * 100
);
} while (resultsSize === pageSize);
return votes;
}

async function downloadVotes(proposalId: string, space: string) {
isDownloadingVotes.value = true;
const proposal = await getProposal(proposalId);
const votes = await getAllVotes(proposalId, space, proposal.votes);
if (!votes.length) return;
const data = votes.map(vote => {
return {
address: vote.voter,
choice: vote.choice,
voting_power: vote.vp,
timestamp: vote.created,
date_utc: new Date(vote.created * 1e3).toUTCString(),
author_ipfs_hash: vote.ipfs
};
});
try {
getCsvFile(
data,
[
'address',
...proposal.choices.map((choice, index) => `choice.${index + 1}`),
'voting_power',
'timestamp',
'date_utc',
'author_ipfs_hash'
],
`${pkg.name}-report-${proposalId}`
);
} catch (e) {
console.error(e);
isDownloadingVotes.value = false;
}
isDownloadingVotes.value = false;
)
.then(async response => {
if (response.status !== 200) {
throw new Error((await response.json()).error.message);
}
return response.blob();
})
.then(blob => {
downloadFile(blob, `${pkg.name}-report-${proposalId}`);
return true;
})
.catch((e: Error) => {
errorCode.value = e;
return false;
})
.finally(() => {
isDownloadingVotes.value = false;
});
}

return {
downloadVotes,
isDownloadingVotes,
downloadProgress
errorCode
};
}
12 changes: 10 additions & 2 deletions src/locales/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -258,8 +258,16 @@
"1": "Votes can be changed while the proposal is active"
}
},
"downloadCsvVotes": "Download as CSV",
"preparingCsvVotes": "Preparing file"
"downloadCsvVotes": {
"title": "Download as CSV",
"postDownloadModal": {
"title": "Download votes report",
"message": {
"pendingGeneration": "The report is generating… Please try again in a few minutes.",
"unknownError": "Unable to contact the download server. Please try again later."
}
}
}
},
"proposals": {
"header": "Proposals",
Expand Down
12 changes: 10 additions & 2 deletions src/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -255,8 +255,16 @@
"1": "Les votes peuvent être modifiés tant que la proposition est active"
}
},
"downloadCsvVotes": "Télécharger en tant que CSV",
"preparingCsvVotes": "Préparation du fichier"
"downloadCsvVotes": {
"title": "Télécharger en tant que CSV",
"postDownloadModal": {
"title": "Téléchargement du rapport",
"message": {
"pendingGeneration": "Le rapport est en cours de génération… Veuillez réessayer dans quelques minutes.",
"unknownError": "Échec de la connexion au serveur de téléchargement. Veuillez réessayer plus tard."
}
}
}
},
"proposals": {
"header": "Propositions",
Expand Down