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

Open
wants to merge 36 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
5816b47
Refactor questions to accept multiple elections
selankon Oct 8, 2024
20264f4
Permit change election form id
selankon Oct 8, 2024
26958af
Extract getVotePackage function
selankon Oct 9, 2024
4920506
Split VoteButton logic
selankon Oct 10, 2024
47e25bf
Refactor name
selankon Oct 10, 2024
ba17720
Fix isInvalid lint error
selankon Oct 10, 2024
9c1ac44
Add MultiElection files
selankon Oct 14, 2024
473d2f3
Simplify validation function
selankon Oct 14, 2024
bd67227
Use relative imports
selankon Oct 14, 2024
321b048
Added ffjavascript resolution to fix version clash issues in tests
elboletaire Oct 14, 2024
8f4a830
Use null instead of empty fragment
selankon Oct 15, 2024
c745e91
Delete sameLengthValidator
selankon Oct 15, 2024
ef03ef0
Merge Multielection with the normal form
selankon Oct 16, 2024
084cbb9
Deprecate multielection confirmation
selankon Oct 18, 2024
4fd42c6
Use election id as form id
selankon Oct 18, 2024
d6e3168
Fix warn message
selankon Oct 18, 2024
41fb9b5
Fix unused variable
selankon Oct 18, 2024
a3a9997
Export component
selankon Oct 21, 2024
8691569
Fix multielection layout
selankon Oct 21, 2024
ec21eae
Implement MultipleElectionVoted
selankon Oct 22, 2024
23d4490
Fix lintern
selankon Oct 22, 2024
a3038b6
Refactor variable name
selankon Oct 22, 2024
2c09335
Create FormFieldValues type
selankon Oct 22, 2024
c9f7176
Expose isDisabled to manually disable the form
selankon Oct 23, 2024
32181c9
Expose onSubmit handler
selankon Oct 24, 2024
47c0eb1
Fix lint
selankon Oct 24, 2024
a9c1796
Fix version
selankon Oct 25, 2024
8fc54f9
Delete uneeded resolution
selankon Oct 25, 2024
ee98753
Fix null
selankon Oct 29, 2024
0c59e5e
Add chakra.form
selankon Nov 4, 2024
aa4958c
Use isDisabled properly
selankon Nov 4, 2024
27eb8cd
Fix renderWith logout
selankon Nov 5, 2024
b1493f3
Implement loaded state
selankon Nov 5, 2024
dd00b01
Fix not renderWith elections loaded state
selankon Nov 6, 2024
4d94f49
Delete global error logic
selankon Nov 11, 2024
562beb1
Add missing dependency
selankon Nov 15, 2024
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"
}
}
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
208 changes: 143 additions & 65 deletions packages/chakra-components/src/components/Election/Questions/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
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 { MultiElectionConfirmation } from './MultiElectionConfirmation'
import { ElectionStateStorage, RenderWith, SubElectionState, SubmitFormValidation } from './Questions'

export const DefaultElectionFormId = 'election-questions'
elboletaire marked this conversation as resolved.
Show resolved Hide resolved

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 +25,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 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])

const vote = async (values: FieldValues) => {
if (!election || !(election instanceof PublishedElection)) {
console.warn('vote attempt with no valid election defined')
// 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 not defined')
elboletaire marked this conversation as resolved.
Show resolved Hide resolved
return false
}

if (
client.wallet instanceof Wallet &&
!(await confirm(
typeof confirmContents === 'function' ? (
confirmContents(election, values)
confirmContents(electionsStates, values)
) : (
<QuestionsConfirmation election={election} answers={values} />
<MultiElectionConfirmation elections={electionsStates} answers={values} />
elboletaire marked this conversation as resolved.
Show resolved Hide resolved
)
))
) {
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,
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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 } from '@vocdoni/sdk'
import { FieldValues } from 'react-hook-form'
import { useConfirm } from '../../layout'
import { ElectionStateStorage } from './Questions'

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

// todo(kon): refactor this to merge it with the current Confirmation modal
elboletaire marked this conversation as resolved.
Show resolved Hide resolved
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} key={election.id}>
// <chakra.div __css={styles.title}>{election.title.default}</chakra.div>
// <chakra.div __css={styles.answer}>{localize('vote.already_voted')}</chakra.div>
// </chakra.div>
// )
elboletaire marked this conversation as resolved.
Show resolved Hide resolved
if (!isAbleToVote)
return (
<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>
)
return (
<Box key={election.id} {...props} sx={styles.box}>
{/*todo(kon): refactor to add election title and if already voted but can overwrite*/}
elboletaire marked this conversation as resolved.
Show resolved Hide resolved
{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>
</>
)
}
Loading
Loading