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

Refactor to accept multiquestion #204

Draft
wants to merge 19 commits into
base: main
Choose a base branch
from
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ vite.config.ts.timestamp-*.mjs
.vscode
.yarn
.yarnrc*
.idea

package.json.backup
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,8 @@
"engines": {
"npm": "please use yarn",
"yarn": ">= 1.19.1 && < 2"
},
"resolutions": {
"ffjavascript": "^0.3.1"
}
}
2 changes: 1 addition & 1 deletion packages/chakra-components/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@vocdoni/chakra-components",
"version": "0.9.6",
"version": "0.9.6-2",
"license": "GPL-3.0-or-later",
"homepage": "https://github.com/vocdoni/ui-components/tree/main/packages/chakra-components#readme",
"bugs": "https://github.com/vocdoni/ui-components/issues",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,55 +3,68 @@ 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 { ElectionResultsTypeNames } from '@vocdoni/sdk'
import { FieldValues } from 'react-hook-form'
import { useConfirm } from '../../layout'
import { ElectionStateStorage } from './Questions'

export type QuestionsConfirmationProps = {
answers: FieldValues
election: PublishedElection
answers: Record<string, FieldValues>
elections: ElectionStateStorage
}

export const QuestionsConfirmation = ({ answers, election, ...rest }: QuestionsConfirmationProps) => {
export const QuestionsConfirmation = ({ answers, elections, ...rest }: QuestionsConfirmationProps) => {
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>
<Box {...props} sx={styles.box}>
<Text sx={styles.description}>{localize('vote.confirm')}</Text>
{election.questions.map((q, k) => {
if (election.resultsType.name === ElectionResultsTypeNames.SINGLE_CHOICE_MULTIQUESTION) {
const choice = q.choices.find((v) => v.value === parseInt(answers[k.toString()], 10))
{Object.values(elections).map(({ election, isAbleToVote }) => {
if (!isAbleToVote)
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 __css={styles.question} key={election.id}>
<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>
)
}
const choices = answers[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>
<>
{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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,15 @@ export const QuestionField = ({ question, index }: QuestionFieldProps) => {
formState: { errors },
} = useFormContext()

const [election, qi] = index.split('.')
const questionIndex = Number(qi)
let isInvalid = false
if (errors[election] && Array.isArray(errors[election]) && errors[election][questionIndex]) {
isInvalid = !!errors[election][questionIndex]
}
return (
<chakra.div __css={styles.question}>
<FormControl isInvalid={!!errors[index]}>
<FormControl isInvalid={isInvalid}>
<chakra.div __css={styles.header}>
<chakra.label __css={styles.title}>{question.title.default}</chakra.label>
</chakra.div>
Expand Down Expand Up @@ -227,7 +233,7 @@ export const SingleChoice = ({ index, question }: QuestionProps) => {
required: localize('validation.required'),
}}
name={index}
render={({ field }) => (
render={({ field, fieldState: { error: fieldError } }) => (
<RadioGroup sx={styles.radioGroup} {...field} isDisabled={disabled}>
<Stack direction='column' sx={styles.stack}>
{question.choices.map((choice, ck) => (
Expand All @@ -236,7 +242,7 @@ export const SingleChoice = ({ index, question }: QuestionProps) => {
</Radio>
))}
</Stack>
<FormErrorMessage sx={styles.error}>{errors[index]?.message as string}</FormErrorMessage>
<FormErrorMessage sx={styles.error}>{fieldError?.message as string}</FormErrorMessage>
</RadioGroup>
)}
/>
Expand Down
204 changes: 140 additions & 64 deletions packages/chakra-components/src/components/Election/Questions/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { Wallet } from '@ethersproject/wallet'
import { useElection } from '@vocdoni/react-providers'
import { ElectionResultsTypeNames, PublishedElection } from '@vocdoni/sdk'
import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect } from 'react'
import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from 'react'
import { FieldValues, FormProvider, useForm, UseFormReturn } from 'react-hook-form'
import { useConfirm } from '../../layout'
import { QuestionsConfirmation } from './Confirmation'
import { ElectionStateStorage, RenderWith, SubElectionState, SubmitFormValidation } from './Questions'

export type QuestionsFormContextState = {
fmethods: UseFormReturn<any>
vote: (values: FieldValues) => Promise<false | void>
}
} & SpecificFormProviderProps &
ReturnType<typeof useMultiElectionsProvider>

const QuestionsFormContext = createContext<QuestionsFormContextState | undefined>(undefined)

Expand All @@ -22,87 +23,162 @@ export const useQuestionsForm = () => {
}

export type QuestionsFormProviderProps = {
confirmContents?: (election: PublishedElection, answers: FieldValues) => ReactNode
confirmContents?: (elections: ElectionStateStorage, answers: Record<string, FieldValues>) => ReactNode
}

export const QuestionsFormProvider: React.FC<PropsWithChildren<QuestionsFormProviderProps>> = ({
confirmContents,
children,
}) => {
// Props that must not be shared with ElectionQuestionsProps
export type SpecificFormProviderProps = {
renderWith?: RenderWith[]
validate?: SubmitFormValidation
}

export const QuestionsFormProvider: React.FC<
PropsWithChildren<QuestionsFormProviderProps & SpecificFormProviderProps>
> = ({ children, ...props }) => {
const fmethods = useForm()
const multiElections = useMultiElectionsProvider({ fmethods, ...props })

return (
<FormProvider {...fmethods}>
<QuestionsFormContext.Provider
value={{ fmethods, renderWith: props.renderWith, validate: props.validate, ...multiElections }}
>
{children}
</QuestionsFormContext.Provider>
</FormProvider>
)
}

export const constructVoteBallot = (election: PublishedElection, choices: FieldValues) => {
let results: number[] = []
switch (election.resultsType.name) {
case ElectionResultsTypeNames.SINGLE_CHOICE_MULTIQUESTION:
results = election.questions.map((q, k) => parseInt(choices[k.toString()], 10))
break
case ElectionResultsTypeNames.MULTIPLE_CHOICE:
results = Object.values(choices)
.pop()
.map((v: string) => parseInt(v, 10))
// map proper abstain ids
if (election.resultsType.properties.canAbstain && results.length < election.voteType.maxCount!) {
let abs = 0
while (results.length < (election.voteType.maxCount || 1)) {
results.push(parseInt(election.resultsType.properties.abstainValues[abs++], 10))
}
}
break
case ElectionResultsTypeNames.APPROVAL:
results = election.questions[0].choices.map((c, k) => {
if (choices[0].includes(k.toString())) {
return 1
} else {
return 0
}
})
break
default:
throw new Error('Unknown or invalid election type')
}
return results
}

const useMultiElectionsProvider = ({
fmethods,
confirmContents,
}: { fmethods: UseFormReturn } & QuestionsFormProviderProps) => {
const { confirm } = useConfirm()
const { election, client, vote: bvote } = useElection()
const { client, isAbleToVote: rootIsAbleToVote, voted: rootVoted, election, vote } = useElection() // Root Election
// 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 vote = async (values: FieldValues) => {
if (!election || !(election instanceof PublishedElection)) {
console.warn('vote attempt with no valid election defined')
const voted = useMemo(
() => (electionsStates && Object.values(electionsStates).every(({ voted }) => voted) ? 'true' : null),
[electionsStates]
)

const isAbleToVote = useMemo(
() => electionsStates && Object.values(electionsStates).some(({ isAbleToVote }) => isAbleToVote),
[electionsStates]
)

// Add an election to the storage
const addElection = (electionState: SubElectionState) => {
setElectionsStates((prev) => ({
...prev,
[(electionState.election as PublishedElection).id]: electionState,
}))
}

// Root election state to be added to the state storage
const rootElectionState: SubElectionState | null = useMemo(() => {
if (!election || !(election instanceof PublishedElection)) return null
return {
vote,
election,
isAbleToVote: rootIsAbleToVote,
voted: rootVoted,
}
}, [vote, election, rootIsAbleToVote, rootVoted])

// 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])

// Add the root election to the state to elections cache
useEffect(() => {
if (!rootElectionState || !rootElectionState.election) return
const actualState = electionsStates[rootElectionState.election.id]
if (rootElectionState.vote === actualState?.vote || rootElectionState.isAbleToVote === actualState?.isAbleToVote) {
return
}
addElection(rootElectionState)
}, [rootElectionState, electionsStates, election])

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

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

let results: number[] = []
switch (election.resultsType.name) {
case ElectionResultsTypeNames.SINGLE_CHOICE_MULTIQUESTION:
results = election.questions.map((q, k) => parseInt(values[k.toString()], 10))
break
case ElectionResultsTypeNames.MULTIPLE_CHOICE:
results = Object.values(values)
.pop()
.map((v: string) => parseInt(v, 10))
// map proper abstain ids
if (election.resultsType.properties.canAbstain && results.length < election.voteType.maxCount!) {
let abs = 0
while (results.length < (election.voteType.maxCount || 1)) {
results.push(parseInt(election.resultsType.properties.abstainValues[abs++], 10))
}
}
break
case ElectionResultsTypeNames.APPROVAL:
results = election.questions[0].choices.map((c, k) => {
if (values[0].includes(k.toString())) {
return 1
} else {
return 0
}
})
break
default:
throw new Error('Unknown or invalid election type')
}

return bvote(results)
}
setVoting(true)

// reset form if account gets disconnected
useEffect(() => {
if (
typeof client.wallet !== 'undefined' ||
!election ||
!(election instanceof PublishedElection) ||
!election?.questions
)
return

fmethods.reset({
...election.questions.reduce((acc, question, index) => ({ ...acc, [index]: '' }), {}),
const votingList = Object.entries(electionsStates).map(([key, { election, vote, voted, isAbleToVote }]) => {
if (!(election instanceof PublishedElection) || !values[election.id] || !isAbleToVote) {
return Promise.resolve()
}
const votePackage = constructVoteBallot(election, values[election.id])
return vote(votePackage)
})
}, [client, election, fmethods])
return Promise.all(votingList).finally(() => setVoting(false))
}

return (
<FormProvider {...fmethods}>
<QuestionsFormContext.Provider value={{ fmethods, vote }}>{children}</QuestionsFormContext.Provider>
</FormProvider>
)
return {
voting,
voteAll,
rootClient: client,
elections: electionsStates,
addElection,
isAbleToVote,
voted,
}
}
Loading