Skip to content

Commit

Permalink
Implement Multielection
Browse files Browse the repository at this point in the history
  • Loading branch information
selankon committed Oct 10, 2024
1 parent 1f5ccd8 commit f43b9ca
Show file tree
Hide file tree
Showing 4 changed files with 357 additions and 3 deletions.
39 changes: 36 additions & 3 deletions src/components/Process/Chained.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { ElectionProvider, useElection } from '@vocdoni/react-providers'
import { InvalidElection, IVotePackage, PublishedElection, VocdoniSDKClient } from '@vocdoni/sdk'
import { useEffect, useState } from 'react'
import { Trans } from 'react-i18next'
import { VoteButton } from './Aside'
import { VoteButton } from '~components/Process/Aside'
import BlindCSPConnect from '~components/Process/BlindCSPConnect'
import { ChainedProvider, useChainedProcesses } from './ChainedContext'
import { ConfirmVoteModal } from './ConfirmVoteModal'
import BlindCSPConnect from '~components/Process/BlindCSPConnect'
import { MultiElectionQuestionsForm, MultiElectionVoteButton } from '~components/Process/MultiElectionQuestions'
import { MultiElectionsProvider } from '~components/Process/MultiElectionContext'

type ChainedProcessesInnerProps = {
connected: boolean
Expand Down Expand Up @@ -48,6 +50,18 @@ const ChainedProcessesInner = ({ connected }: ChainedProcessesInnerProps) => {
})()
}, [processes, current, voted, client])

const [renderWith, setRenderWith] = useState<RenderWith[]>([])
// Effect to set renderWith component state.
useEffect(() => {
if (!current || processes[current] instanceof InvalidElection) return
const currentElection = processes[current]
const meta = currentElection.get('multiprocess')
if (meta.renderWith) {
setRenderWith(meta.renderWith)
}
}, [current, processes])
const isRenderWith = renderWith.length > 0

if (!current || !processes[current]) {
return <Progress w='full' size='xs' isIndeterminate />
}
Expand All @@ -56,6 +70,17 @@ const ChainedProcessesInner = ({ connected }: ChainedProcessesInnerProps) => {
return <Trans i18nKey='error.election_is_invalid'>Invalid election</Trans>
}

if (isRenderWith) {
return (
<MultiElectionsProvider renderWith={[{ id: current }, ...renderWith]} rootClient={client}>
<MultiElectionQuestionsForm ConnectButton={ConnectButton} />
<Box position='sticky' bottom={0} left={0} pb={1} pt={1} display={{ base: 'none', lg2: 'block' }}>
<VoteButton />
</Box>
</MultiElectionsProvider>
)
}

return (
<Box className='md-sizes' mb='100px' pt='25px'>
<ElectionQuestions
Expand Down Expand Up @@ -114,6 +139,10 @@ const ChainedProcessesWrapper = () => {
return <Progress w='full' size='xs' isIndeterminate />
}

if (processes[current] instanceof InvalidElection) {
return <Trans i18nKey='error.election_is_invalid'>Invalid election</Trans>
}

const isBlindCsp = election.get('census.type') === 'csp' && election?.meta.csp?.service === 'vocdoni-blind-csp'

return (
Expand Down Expand Up @@ -274,7 +303,11 @@ type FlowCondition = {
goto: string
}

// FlowNode can have or conditions or renderWith, but not both
type FlowNode = {
default: string
conditions?: FlowCondition[]
} & ({ conditions?: FlowCondition[]; renderWith?: never } | { conditions?: never; renderWith: RenderWith[] })

export type RenderWith = {
id: string
}
87 changes: 87 additions & 0 deletions src/components/Process/MultiElectionConfirmation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { Button } from '@chakra-ui/button'
import { Box, Text } from '@chakra-ui/layout'
import { ModalBody, ModalCloseButton, ModalFooter, ModalHeader } from '@chakra-ui/modal'
import { chakra, omitThemingProps, useMultiStyleConfig } from '@chakra-ui/system'
import { useClient } from '@vocdoni/react-providers'
import { ElectionResultsTypeNames, PublishedElection } from '@vocdoni/sdk'
import { FieldValues } from 'react-hook-form'
import { useConfirm } from '@vocdoni/chakra-components'
import { ElectionStateStorage } from '~components/Process/MultiElectionContext'

export type MultiElectionConfirmationProps = {
answers: Record<string, FieldValues>
elections: ElectionStateStorage
}

export const MultiElectionConfirmation = ({ answers, elections, ...rest }: MultiElectionConfirmationProps) => {
const mstyles = useMultiStyleConfig('ConfirmModal')
const styles = useMultiStyleConfig('QuestionsConfirmation', rest)
const { cancel, proceed } = useConfirm()
const props = omitThemingProps(rest)
const { localize } = useClient()
return (
<>
<ModalHeader sx={mstyles.header}>{localize('confirm.title')}</ModalHeader>
<ModalCloseButton sx={mstyles.close} />
<ModalBody sx={mstyles.body}>
<Text sx={styles.description}>{localize('vote.confirm')}</Text>
{Object.values(elections).map(({ election, voted, isAbleToVote }) => {
if (voted)
return (
<chakra.div __css={styles.question}>
<chakra.div __css={styles.title}>{election.title.default}</chakra.div>
<chakra.div __css={styles.answer}>{localize('vote.already_voted')}</chakra.div>
</chakra.div>
)
if (!isAbleToVote)
return (
<chakra.div __css={styles.question}>
<chakra.div __css={styles.title}>{election.title.default}</chakra.div>
<chakra.div __css={styles.answer}>{localize('vote.not_able_to_vote')}</chakra.div>
</chakra.div>
)
return (
<Box key={election.id} {...props} sx={styles.box}>
{election.questions.map((q, k) => {
if (election.resultsType.name === ElectionResultsTypeNames.SINGLE_CHOICE_MULTIQUESTION) {
const choice = q.choices.find((v) => v.value === parseInt(answers[election.id][k.toString()], 10))
return (
<chakra.div key={k} __css={styles.question}>
<chakra.div __css={styles.title}>{q.title.default}</chakra.div>
<chakra.div __css={styles.answer}>{choice?.title.default}</chakra.div>
</chakra.div>
)
}
const choices = answers[election.id][0]
.map((a: string) =>
q.choices[Number(a)] ? q.choices[Number(a)].title.default : localize('vote.abstain')
)
.map((a: string) => (
<span>
- {a}
<br />
</span>
))

return (
<chakra.div key={k} __css={styles.question}>
<chakra.div __css={styles.title}>{q.title.default}</chakra.div>
<chakra.div __css={styles.answer}>{choices}</chakra.div>
</chakra.div>
)
})}
</Box>
)
})}
</ModalBody>
<ModalFooter sx={mstyles.footer}>
<Button onClick={cancel!} variant='ghost' sx={mstyles.cancel}>
{localize('confirm.cancel')}
</Button>
<Button onClick={proceed!} sx={mstyles.confirm}>
{localize('confirm.confirm')}
</Button>
</ModalFooter>
</>
)
}
126 changes: 126 additions & 0 deletions src/components/Process/MultiElectionContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import React, { createContext, FC, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from 'react'
import { FieldValues, FormProvider, useForm, UseFormReturn } from 'react-hook-form'
import { PublishedElection, VocdoniSDKClient } from '@vocdoni/sdk'
import { Wallet } from '@ethersproject/wallet'
import { useElection, ElectionState } from '@vocdoni/react-providers'
import { MultiElectionConfirmation } from './MultiElectionConfirmation'
import { useConfirm, getVotePackage } from '@vocdoni/chakra-components'

export type MultiElectionFormContextState = {
fmethods: UseFormReturn<any>
} & ReturnType<typeof useMultiElectionsProvider>

export const MultiElectionsContext = createContext<MultiElectionFormContextState | undefined>(undefined)

export type MultiElectionsProviderProps = {
renderWith: RenderWith[]
rootClient: VocdoniSDKClient
confirmContents?: (elections: ElectionStateStorage, answers: Record<string, FieldValues>) => ReactNode
}

export type RenderWith = {
id: string
}

export const MultiElectionsProvider: FC<PropsWithChildren<MultiElectionsProviderProps>> = ({ children, ...props }) => {
const fmethods = useForm()

const multiElections = useMultiElectionsProvider({ fmethods, ...props })
return (
<FormProvider {...fmethods}>
<MultiElectionsContext.Provider value={{ fmethods, ...multiElections }}>
{children}
</MultiElectionsContext.Provider>
</FormProvider>
)
}

export type SubElectionState = { election: PublishedElection } & Pick<ElectionState, 'vote' | 'isAbleToVote' | 'voted'>
export type ElectionStateStorage = Record<string, SubElectionState>

const useMultiElectionsProvider = ({
fmethods,
renderWith,
rootClient,
confirmContents,
}: { fmethods: UseFormReturn } & MultiElectionsProviderProps) => {
const { confirm } = useConfirm()
const { client } = useElection()
// State to store on memory the loaded elections to pass it into confirm modal to show the info
const [electionsStates, setElectionsStates] = useState<ElectionStateStorage>({})
const [voting, setVoting] = useState<boolean>(false)

const addElection = (electionState: SubElectionState) => {
setElectionsStates((prev) => ({
...prev,
[(electionState.election as PublishedElection).id]: electionState,
}))
}

const voteAll = async (values: Record<string, FieldValues>) => {
if (!electionsStates || Object.keys(electionsStates).length === 0) {
console.warn('vote attempt with no valid elections not defined')
return false
}

if (
client.wallet instanceof Wallet &&
!(await confirm(
typeof confirmContents === 'function' ? (
confirmContents(electionsStates, values)
) : (
<MultiElectionConfirmation elections={electionsStates} answers={values} />
)
))
) {
return false
}

setVoting(true)

const votingList = Object.entries(electionsStates).map(([key, { election, vote }]) => {
if (!(election instanceof PublishedElection) || !values[election.id]) return Promise.resolve()
const votePackage = getVotePackage(election, values[election.id])
return vote(votePackage)
})
return Promise.all(votingList).finally(() => setVoting(false))
}

// reset form if account gets disconnected
useEffect(() => {
if (typeof client.wallet !== 'undefined') return

setElectionsStates({})
fmethods.reset({
...Object.values(electionsStates).reduce((acc, { election }) => ({ ...acc, [election.id]: '' }), {}),
})
}, [client, electionsStates, fmethods])

const voted = useMemo(
() => (electionsStates && Object.values(electionsStates).every(({ voted }) => voted) ? 'true' : null),
[electionsStates]
)
const isAbleToVote = useMemo(
() => electionsStates && Object.values(electionsStates).some(({ isAbleToVote }) => isAbleToVote),
[electionsStates]
)

return {
voting,
voteAll,
renderWith,
rootClient,
elections: electionsStates,
addElection,
isAbleToVote,
voted,
}
}

export const useMultiElections = () => {
const context = useContext(MultiElectionsContext)
if (!context) {
throw new Error('useMultiElections must be used within an MultiElectionsProvider')
}
return context
}
108 changes: 108 additions & 0 deletions src/components/Process/MultiElectionQuestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {
MultiElectionsProvider,
MultiElectionsProviderProps,
SubElectionState,
useMultiElections,
} from './MultiElectionContext'
import { ElectionProvider, useElection } from '@vocdoni/react-providers'
import { ComponentType, useEffect, useMemo, useState } from 'react'
import { PublishedElection } from '@vocdoni/sdk'
import { ButtonProps } from '@chakra-ui/button'
import {
ElectionQuestionsFormProps,
ElectionQuestion,
DefaultElectionFormId,
VoteButtonLogic,
} from '@vocdoni/chakra-components'

export type MultiElectionQuestionsFormProps = { ConnectButton?: ComponentType } & ElectionQuestionsFormProps

export const MultiElectionVoteButton = (props: ButtonProps) => {
const { isAbleToVote, voting, voted } = useMultiElections()
const election = useElection() // use Root election information

return (
<VoteButtonLogic
electionState={{ ...election, voted, loading: { ...election.loading, voting }, isAbleToVote }}
{...props}
/>
)
}

export const MultiElectionQuestionsForm = ({
formId,
onInvalid,
ConnectButton,
...props
}: MultiElectionQuestionsFormProps) => {
const { voteAll, fmethods, renderWith, elections, addElection } = useMultiElections()

return (
<form onSubmit={fmethods.handleSubmit(voteAll, onInvalid)} id={formId ?? DefaultElectionFormId}>
{/*<ElectionQuestion {...props} />*/}
{renderWith.length > 0 && (
<>
{renderWith.map(({ id }) => (
<ElectionProvider key={id} ConnectButton={ConnectButton} id={id} fetchCensus>
<SubElectionQuestions {...props} />
</ElectionProvider>
))}
</>
)}
</form>
)
}

const SubElectionQuestions = (props: Omit<MultiElectionQuestionsFormProps, 'ConnectButton'>) => {
const { rootClient, addElection, elections } = useMultiElections()
const { election, setClient, vote, client, connected, clearClient, isAbleToVote, voted } = useElection()

const subElectionState: SubElectionState | null = useMemo(() => {
if (!election || !(election instanceof PublishedElection)) return null
return {
vote,
election,
isAbleToVote,
voted,
}
}, [vote, election, isAbleToVote, voted])

// clear session of local context when login out
useEffect(() => {
if (connected) return
clearClient()
}, [connected])

// ensure the client is set to the root one
useEffect(() => {
setClient(rootClient)
}, [rootClient, election])

// Update election state cache
useEffect(() => {
if (!subElectionState || !subElectionState.election) return
const actualState = elections[subElectionState.election.id]
if (subElectionState.vote === actualState?.vote || subElectionState.isAbleToVote === actualState?.isAbleToVote) {
return
}
addElection(subElectionState)

// }
// ;(async () => {
// if (
// election &&
// election instanceof PublishedElection
// // client?.wallet &&
// // typeof client.wallet.getAddress === 'function'
// ) {
// // Store the election if wallet contain address
// // const address = await client.wallet.getAddress()
// // if (walletAddress === address && elections[election.id]) return
// // setWalletAddress(address)
// addElection({ election, vote, isAbleToVote, voted })
// }
// })()
}, [subElectionState, elections, election])

return <ElectionQuestion {...props} />
}

0 comments on commit f43b9ca

Please sign in to comment.