Skip to content

Commit

Permalink
Enable voting for delegators (#10)
Browse files Browse the repository at this point in the history
* stakers can vote

* validation fixes

* vote form fixes

* deleting unnecessary dependencies

* removed form disabling

* tooltip for unselected option

* removing styles from tooltip

* graphql rewrote to rest

* getUserVoteTypeFromProposal typing

---------

Co-authored-by: Sorizen <[email protected]>
  • Loading branch information
Sorizen and Sorizen authored Apr 8, 2024
1 parent c91332a commit 987c353
Show file tree
Hide file tree
Showing 17 changed files with 221 additions and 34 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog], and this project adheres to [Semantic Versioning].

## [Unreleased]
### Changed
- Allow `delegators` to vote for proposal

## [1.3.0] - 2024-03-27
### Changed
- `@rarimo/client` to support amino signer
Expand Down
1 change: 1 addition & 0 deletions scripts/release-sanity-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ function getVersion() {

const refsReport = exec('git log -1 --format="%D"').toString()
const versionMatch = refsReport.match(/tag: ([\w\d\-_.]+)/i)

return versionMatch ? versionMatch[1] : ''
}

Expand Down
24 changes: 23 additions & 1 deletion src/callers/proposal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CONFIG } from '@/config'
import { getApollo } from '@/graphql'
import { VoteStates } from '@/enums'
import {
getApollo,
GetProposalBase,
GetProposalBaseQuery,
GetProposalById,
Expand All @@ -21,6 +22,14 @@ import {
ProposalVoteFragment,
} from '@/graphql'

type UserVoteType = {
vote: {
options: {
option: VoteStates
}[]
}
}

export const getProposalList = async (
limit: number = CONFIG.PAGE_LIMIT,
offset = 0,
Expand Down Expand Up @@ -100,3 +109,16 @@ export const getProposalVotesCountByID = async (id: string): Promise<number> =>

return data?.proposal?.[0]?.proposal_votes_aggregate.aggregate?.count ?? 0
}

export const getUserVoteTypeFromProposal = async (
proposalId: string | number,
address: string,
): Promise<VoteStates> => {
const response = await fetch(
`${CONFIG.CHAIN_API_URL}/cosmos/gov/v1beta1/proposals/${proposalId}/votes/${address}`,
)

const { vote }: UserVoteType = await response.json()

return vote.options[0].option
}
101 changes: 86 additions & 15 deletions src/components/Forms/VoteForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Radio,
RadioGroup,
Select,
Tooltip,
Typography,
} from '@mui/material'
import {
Expand All @@ -16,10 +17,14 @@ import {
VoteOption,
} from '@rarimo/client'
import { omit } from 'lodash-es'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Controller } from 'react-hook-form'
import * as Yup from 'yup'

import { getUserVoteTypeFromProposal } from '@/callers'
import { getClient } from '@/client'
import FormWrapper from '@/components/Forms/FormWrapper'
import { VoteStates } from '@/enums'
import { Bus, ErrorHandler } from '@/helpers'
import { useForm, useLocalize, useWeb3 } from '@/hooks'
import { useI18n } from '@/locales/client'
Expand All @@ -36,6 +41,13 @@ enum VoteFormFieldNames {
Voter = 'voter',
}

const VOTE_TYPES: Record<string, VoteStates> = {
[VoteOption.Yes]: VoteStates.Yes,
[VoteOption.No]: VoteStates.No,
[VoteOption.Abstain]: VoteStates.Abstain,
[VoteOption.NoWithVeto]: VoteStates.Veto,
}

export default function VoteForm({
id,
onSubmit,
Expand All @@ -44,29 +56,76 @@ export default function VoteForm({
proposalId,
}: FormProps & { proposalId: number; grants: GrantAuthorization[] }) {
const t = useI18n()
const { address, isValidator } = useWeb3()
const { address, isValidator, isStaker } = useWeb3()
const { localizeProposalVoteOption } = useLocalize()

const [alreadySelectedVote, setAlreadySelectedVote] = useState<VoteStates | ''>('')

const DEFAULT_VALUES = {
[VoteFormFieldNames.Option]: VoteOption.Yes,
[VoteFormFieldNames.Voter]: isValidator ? address : grants?.[0]?.granter,
}

const getVoterValidationRule = (yup: typeof Yup): Yup.ObjectShape => {
if (!isStaker && grants.length) {
return { [VoteFormFieldNames.Voter]: yup.string().required() }
}
if (isStaker && grants.length) {
return { [VoteFormFieldNames.Voter]: yup.string() }
}
return {}
}

const {
handleSubmit,
control,
isFormDisabled,
formErrors,
formState,
setValue,
disableForm,
enableForm,
getErrorMessage,
} = useForm(DEFAULT_VALUES, yup =>
yup.object({
...getVoterValidationRule(yup),
[VoteFormFieldNames.Option]: yup.number().required(),
[VoteFormFieldNames.Voter]: yup.string().required(),
}),
)

const selectOptions = useMemo(
() => (grants.length ? [...grants, { granter: address }] : []),
[address, grants],
)

const setNewDefaultVoteOption = useCallback(
(voteType: string) => {
const newDefaultStatus = Object.values(VoteStates).find(item => item !== voteType)
const newDefaultOption = Object.keys(VOTE_TYPES).find(
key => VOTE_TYPES[key] === newDefaultStatus,
) as unknown as VoteOption
setValue(VoteFormFieldNames.Option, newDefaultOption)
},
[setValue],
)

const getIsChosenAddressAlreadyVotedForProposal = useCallback(
async (addressForChecking: string) => {
try {
setAlreadySelectedVote('')
const voteType = await getUserVoteTypeFromProposal(proposalId, addressForChecking)
if (voteType) {
setNewDefaultVoteOption(voteType)

setAlreadySelectedVote(voteType)
}
} catch (e) {
ErrorHandler.processWithoutFeedback(e as Error)
}
},
[proposalId, setNewDefaultVoteOption],
)

const submit = async (formData: typeof DEFAULT_VALUES) => {
disableForm()
setIsDialogDisabled(true)
Expand All @@ -81,11 +140,9 @@ export default function VoteForm({
return
}

if (formData.voter !== address) {
await client.tx.execVoteProposal(address, formData.voter, proposalId, formData.option)
} else {
await client.tx.voteProposal(address, proposalId, formData[VoteFormFieldNames.Option])
}
formData.voter === address
? await client.tx.voteProposal(address, proposalId, formData[VoteFormFieldNames.Option])
: await client.tx.execVoteProposal(address, formData.voter, proposalId, formData.option)

onSubmit({
message: t('vote-form.submitted-msg', {
Expand All @@ -99,13 +156,17 @@ export default function VoteForm({
setIsDialogDisabled(false)
}

useEffect(() => {
getIsChosenAddressAlreadyVotedForProposal(formState.voter || address)
}, [formState.voter, address, getIsChosenAddressAlreadyVotedForProposal])

return (
<FormWrapper id={id} onSubmit={handleSubmit(submit)} isFormDisabled={isFormDisabled}>
<Typography variant={'body2'} color={'var(--col-txt-secondary)'}>
{t('vote-form.helper-text')}
</Typography>

{grants.length ? (
{selectOptions.length ? (
<Controller
name={VoteFormFieldNames.Voter}
control={control}
Expand All @@ -121,8 +182,8 @@ export default function VoteForm({
disabled={isFormDisabled}
error={Boolean(formErrors[VoteFormFieldNames.Voter])}
>
{grants.map((item, idx) => (
<MenuItem value={item.granter} key={idx}>
{selectOptions.map((item, idx) => (
<MenuItem key={idx} value={item.granter}>
{item.granter}
</MenuItem>
))}
Expand All @@ -146,12 +207,22 @@ export default function VoteForm({
<FormControl>
<RadioGroup {...field}>
{VOTE_OPTIONS.map((option, idx) => (
<FormControlLabel
value={option}
control={<Radio />}
label={localizeProposalVoteOption(option as VoteOption)}
<Tooltip
placement='bottom-start'
followCursor
key={idx}
/>
title={t('vote-form.already-voted-option')}
disableHoverListener={VOTE_TYPES[option] !== alreadySelectedVote}
disableTouchListener={VOTE_TYPES[option] !== alreadySelectedVote}
>
<FormControlLabel
value={option}
control={<Radio />}
disabled={VOTE_TYPES[option] === alreadySelectedVote}
label={localizeProposalVoteOption(option as VoteOption)}
key={idx}
/>
</Tooltip>
))}
</RadioGroup>
</FormControl>
Expand Down
27 changes: 16 additions & 11 deletions src/components/Proposal/Proposal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const VOTE_FORM_ID = 'vote-form'

export default function Proposal({ id }: { id: string }) {
const t = useI18n()
const { isConnected, isValidator, address } = useWeb3()
const { isConnected, isValidator, isStaker, address } = useWeb3()

const { data, isLoading, isLoadingError, reload } = useLoading<ProposalFragment>(
{} as ProposalFragment,
Expand Down Expand Up @@ -65,14 +65,24 @@ export default function Proposal({ id }: { id: string }) {
const tooltipText = useMemo(() => {
if (!isVotingAllowed) return t('proposal.vote-not-allowed-msg')
if (!isConnected) return t('proposal.connect-wallet-msg')
if (!isValidator && isGrantsEmpty) return t('proposal.not-validator-msg')
if (!isValidator && !isGrantsEmpty && !isStaker) return t('proposal.not-staker-msg')
if (!isValidator && isGrantsEmpty && !isStaker) return t('proposal.not-validator-msg')

return ''
}, [isValidator, isVotingAllowed, isGrantsEmpty, isConnected, t])
}, [isValidator, isVotingAllowed, isGrantsEmpty, isConnected, t, isStaker])

const isTooltipListenersDisable = useMemo(
() => (isValidator || !isGrantsEmpty) && isVotingAllowed && isConnected,
[isValidator, isGrantsEmpty, isVotingAllowed, isConnected],
() => (isValidator || !isGrantsEmpty || isStaker) && isVotingAllowed && isConnected,
[isValidator, isGrantsEmpty, isVotingAllowed, isConnected, isStaker],
)

const isVoteButtonDisabled = useMemo(
() =>
isDisabled ||
!isVotingAllowed ||
(!isValidator && isGrantsEmpty && !isStaker) ||
!isConnected,
[isDisabled, isVotingAllowed, isValidator, isGrantsEmpty, isConnected, isStaker],
)

const sectionAction = (
Expand All @@ -86,12 +96,7 @@ export default function Proposal({ id }: { id: string }) {
disableTouchListener={isTooltipListenersDisable}
>
<span>
<Button
onClick={openDialog}
disabled={
isDisabled || !isVotingAllowed || (!isValidator && isGrantsEmpty) || !isConnected
}
>
<Button onClick={openDialog} disabled={isVoteButtonDisabled}>
{t('proposal.vote-btn')}
</Button>
</span>
Expand Down
1 change: 1 addition & 0 deletions src/enums/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './proposal-vote-states.enum'
6 changes: 6 additions & 0 deletions src/enums/proposal-vote-states.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export enum VoteStates {
Yes = 'VOTE_OPTION_YES',
Abstain = 'VOTE_OPTION_ABSTAIN',
No = 'VOTE_OPTION_NO',
Veto = 'VOTE_OPTION_NO_WITH_VETO',
}
31 changes: 31 additions & 0 deletions src/graphql/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19069,13 +19069,28 @@ export type ValidatorFragment = { __typename?: 'validator', consensus_address: s

export type ValidatorBaseFragment = { __typename?: 'validator', validator_info?: { __typename?: 'validator_info', operator_address: string } | null, validator_commissions: Array<{ __typename?: 'validator_commission', commission: any }>, validator_signing_infos: Array<{ __typename?: 'validator_signing_info', missed_blocks_counter: any }>, validator_descriptions: Array<{ __typename?: 'validator_description', moniker?: string | null, avatar_url?: string | null }>, validator_statuses: Array<{ __typename?: 'validator_status', status: number, jailed: boolean }>, validator_voting_powers: Array<{ __typename?: 'validator_voting_power', voting_power: any }> };

export type GetAccountDelegationsQueryVariables = Exact<{
address: Scalars['String']['input'];
}>;


export type GetAccountDelegationsQuery = { __typename?: 'query_root', action_delegation?: { __typename?: 'ActionDelegationResponse', pagination?: any | null } | null };

export type GetAccountValidatorInfosQueryVariables = Exact<{
address: Scalars['String']['input'];
}>;


export type GetAccountValidatorInfosQuery = { __typename?: 'query_root', account: Array<{ __typename?: 'account', validator_infos: Array<{ __typename?: 'validator_info', consensus_address: string }> }> };

export type GetAccountVoteForProposalQueryVariables = Exact<{
proposalId: Scalars['Int']['input'];
address: Scalars['String']['input'];
}>;


export type GetAccountVoteForProposalQuery = { __typename?: 'query_root', proposal_vote: Array<{ __typename?: 'proposal_vote', option: string }> };

export type GetBlockByHeightQueryVariables = Exact<{
height: Scalars['bigint']['input'];
}>;
Expand Down Expand Up @@ -19799,6 +19814,13 @@ export const ValidatorBase = gql`
}
}
`;
export const GetAccountDelegations = gql`
query GetAccountDelegations($address: String!) {
action_delegation(address: $address) {
pagination
}
}
`;
export const GetAccountValidatorInfos = gql`
query GetAccountValidatorInfos($address: String!) {
account(where: {address: {_eq: $address}}) {
Expand All @@ -19808,6 +19830,15 @@ export const GetAccountValidatorInfos = gql`
}
}
`;
export const GetAccountVoteForProposal = gql`
query GetAccountVoteForProposal($proposalId: Int!, $address: String!) {
proposal_vote(
where: {account: {address: {_eq: $address}}, proposal_id: {_eq: $proposalId}}
) {
option
}
}
`;
export const GetBlockByHeight = gql`
query GetBlockByHeight($height: bigint!) {
block(where: {height: {_eq: $height}}) {
Expand Down
5 changes: 5 additions & 0 deletions src/graphql/queries/GetAccountDelegations.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
query GetAccountDelegations($address: String!) {
action_delegation(address: $address) {
pagination
}
}
2 changes: 2 additions & 0 deletions src/hooks/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const useForm = <T extends Yup.AnyObjectSchema, R extends object>(
handleSubmit,
watch,
formState: { errors },
setValue,
} = useFormHook({
mode: 'onTouched',
reValidateMode: 'onChange',
Expand Down Expand Up @@ -54,5 +55,6 @@ export const useForm = <T extends Yup.AnyObjectSchema, R extends object>(
register,
handleSubmit,
control,
setValue,
}
}
Loading

0 comments on commit 987c353

Please sign in to comment.