diff --git a/backend/main/models/proposal.go b/backend/main/models/proposal.go index c1347370b..3a2ff83e0 100644 --- a/backend/main/models/proposal.go +++ b/backend/main/models/proposal.go @@ -42,6 +42,7 @@ type Proposal struct { Voucher *shared.Voucher `json:"voucher,omitempty"` Achievements_done bool `json:"achievementsDone"` TallyMethod string `json:"voteType" validate:"required"` + Quorum *float64 `json:"quorum,omitempty"` } type UpdateProposalRequestPayload struct { @@ -146,9 +147,10 @@ func (p *Proposal) CreateProposal(db *s.Database) error { cid, composite_signatures, voucher, - tally_method + tally_method, + quorum ) - VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING id, created_at `, p.Community_id, @@ -167,6 +169,7 @@ func (p *Proposal) CreateProposal(db *s.Database) error { p.Composite_signatures, p.Voucher, p.TallyMethod, + p.Quorum, ).Scan(&p.ID, &p.Created_at) return err @@ -210,17 +213,19 @@ func (p *Proposal) UpdateDraftProposal(db *s.Database) error { strategy = COALESCE($3, strategy), min_balance = COALESCE($4, min_balance), max_weight = COALESCE($5, max_weight), - start_time = COALESCE($6, start_time), - end_time = COALESCE($7, end_time), - body = COALESCE($8, body), - block_height = COALESCE($9, block_height), - cid = COALESCE($10, cid) - WHERE id = $11 + quorum = COALESCE($6, quorum), + start_time = COALESCE($7, start_time), + end_time = COALESCE($8, end_time), + body = COALESCE($9, body), + block_height = COALESCE($10, block_height), + cid = COALESCE($11, cid) + WHERE id = $12 `, p.Name, p.Choices, p.Strategy, p.Min_balance, p.Max_weight, + p.Quorum, p.Start_time, p.End_time, p.Body, diff --git a/backend/main/server/controllers.go b/backend/main/server/controllers.go index 750222315..52de1ea20 100644 --- a/backend/main/server/controllers.go +++ b/backend/main/server/controllers.go @@ -398,7 +398,6 @@ func (a *App) createProposal(w http.ResponseWriter, r *http.Request) { var p models.Proposal p.Community_id = communityId - if err := validatePayload(r.Body, &p); err != nil { log.Error().Err(err).Msg("Error validating payload") respondWithError(w, errIncompleteRequest) diff --git a/backend/main/server/helpers.go b/backend/main/server/helpers.go index 9e8cc4920..7832b0694 100644 --- a/backend/main/server/helpers.go +++ b/backend/main/server/helpers.go @@ -606,7 +606,7 @@ func (h *Helpers) createProposal(p models.Proposal) (models.Proposal, errorRespo canUserCreateProposal := community.CanUserCreateProposal(h.A.DB, h.A.FlowAdapter, p.Creator_addr) - if err := handlePermissionErrorr(canUserCreateProposal); err != nilErr { + if err := handlePermissionError(canUserCreateProposal); err != nilErr { return models.Proposal{}, err } @@ -652,7 +652,7 @@ func (h *Helpers) createDraftProposal(c models.Community, p models.Proposal) (mo p.Creator_addr, ) - if err := handlePermissionErrorr(canUserCreateProposal); err != nilErr { + if err := handlePermissionError(canUserCreateProposal); err != nilErr { return models.Proposal{}, err } @@ -729,7 +729,7 @@ func (h *Helpers) updateDraftProposal(p models.Proposal) (models.Proposal, error p.Creator_addr, ) - if err := handlePermissionErrorr(canUserCreateProposal); err != nilErr { + if err := handlePermissionError(canUserCreateProposal); err != nilErr { return models.Proposal{}, err } @@ -742,7 +742,7 @@ func (h *Helpers) updateDraftProposal(p models.Proposal) (models.Proposal, error return p, nilErr } -func handlePermissionErrorr(result models.CanUserCreateProposalResponse) errorResponse { +func handlePermissionError(result models.CanUserCreateProposalResponse) errorResponse { // If user doesn't have permission, populate errorResponse // with reason and error. if !result.HasPermission { diff --git a/backend/migrations/000044_add_quorum_column.down.sql b/backend/migrations/000044_add_quorum_column.down.sql new file mode 100644 index 000000000..086cab85d --- /dev/null +++ b/backend/migrations/000044_add_quorum_column.down.sql @@ -0,0 +1 @@ +ALTER TABLE proposals DROP COLUMN IF EXISTS quorum; diff --git a/backend/migrations/000044_add_quorum_column.up.sql b/backend/migrations/000044_add_quorum_column.up.sql new file mode 100644 index 000000000..1a51df8f8 --- /dev/null +++ b/backend/migrations/000044_add_quorum_column.up.sql @@ -0,0 +1 @@ +ALTER TABLE proposals ADD COLUMN quorum float; \ No newline at end of file diff --git a/frontend/packages/client/src/api/proposals.js b/frontend/packages/client/src/api/proposals.js index 86f0fad92..6461c62a0 100644 --- a/frontend/packages/client/src/api/proposals.js +++ b/frontend/packages/client/src/api/proposals.js @@ -1,7 +1,7 @@ import { API_BASE_URL, COMMUNITIES_URL, - IPFS_GETWAY, + IPFS_GATEWAY, PROPOSALS_URL, } from './constants'; import { checkResponse } from 'utils'; @@ -20,10 +20,25 @@ export const fetchProposal = async ({ proposalId }) => { const response = await fetch(url); const proposal = await checkResponse(response); - const sortedProposalChoices = + let sortedProposalChoices = proposal.choices?.sort((a, b) => (a.choiceText > b.choiceText ? 1 : -1)) ?? []; + if (proposal.voteType === 'basic') { + // Ensure choices are ordered as For/Against/Abstain for basic voting + const choices = [...sortedProposalChoices]; + sortedProposalChoices.forEach((choice) => { + let index = 0; + if (choice.choiceText === 'Against') { + index = 1; + } else if (choice.choiceText === 'Abstain') { + index = 2; + } + choices[index] = choice; + }); + sortedProposalChoices = choices; + } + const proposalData = { ...proposal, choices: sortedProposalChoices.map((choice) => ({ @@ -32,7 +47,7 @@ export const fetchProposal = async ({ proposalId }) => { choiceImgUrl: choice.choiceImgUrl, })), ipfs: proposal.cid, - ipfsUrl: `${IPFS_GETWAY}/${proposal.cid}`, + ipfsUrl: `${IPFS_GATEWAY}/${proposal.cid}`, totalVotes: proposal.total_votes, // this is coming as a string from db but there could be multiple based on design strategy: proposal.strategy || '-', @@ -48,6 +63,15 @@ export const createProposalApiReq = async ({ timestamp, } = {}) => { const { communityId, ...proposalData } = proposalPayload; + + if (proposalData.voteType === 'basic') { + proposalData.choices = [ + { choiceText: 'For', choiceImgUrl: null }, + { choiceText: 'Against', choiceImgUrl: null }, + { choiceText: 'Abstain', choiceImgUrl: null }, + ]; + } + const url = `${COMMUNITIES_URL}/${communityId}/proposals`; const fetchOptions = { method: 'POST', diff --git a/frontend/packages/client/src/components/Proposal/VoteHeader.js b/frontend/packages/client/src/components/Proposal/VoteHeader.js index 5fa205a5b..8953c34ea 100644 --- a/frontend/packages/client/src/components/Proposal/VoteHeader.js +++ b/frontend/packages/client/src/components/Proposal/VoteHeader.js @@ -9,7 +9,7 @@ const Wrapper = ({ children }) => ( ); -export default function VoteHeader({ status }) { +export default function VoteHeader({ status, voteType = 'single-choice' }) { // Status: user-voted, invite-to-vote, is-closed const message = { 'user-voted': ( @@ -20,7 +20,12 @@ export default function VoteHeader({ status }) { You successfully voted on this proposal! ), - 'invite-to-vote': <>Rank your vote ✨, + 'invite-to-vote': ( + <> + {voteType === 'single-choice' ? 'Cast your vote' : 'Rank your vote'}{' '} + ✨ + + ), 'is-closed': <>Voting has ended on this proposal., }; diff --git a/frontend/packages/client/src/components/ProposalCreate/FormConfig.js b/frontend/packages/client/src/components/ProposalCreate/FormConfig.js index 4936ef7f8..6550259b2 100644 --- a/frontend/packages/client/src/components/ProposalCreate/FormConfig.js +++ b/frontend/packages/client/src/components/ProposalCreate/FormConfig.js @@ -46,12 +46,21 @@ const StepTwoSchema = yup.object().shape({ }) ), }) - .when('voteType', (voteType, schema) => - voteType === 'single-choice' + .when('voteType', (voteType, schema) => { + if (voteType === 'basic') return; + return voteType === 'single-choice' ? schema.min(2, 'Please add a choice, minimum amount is two') - : schema.min(3, 'Please add a choice, minimum amount is three') - ) + : schema.min(3, 'Please add a choice, minimum amount is three'); + }) .unique('value', 'Invalid duplicated option'), + quorum: yup + .string() + .trim() + .matches(/\s+$|^$|(^[0-9]+$)/, 'Quorum threshold must be a valid number') + .when('voteType', (voteType, schema) => { + if (voteType !== 'basic') return; + return schema.required('Quorum threshold is required'); + }), maxWeight: yup .string() .trim() diff --git a/frontend/packages/client/src/components/ProposalCreate/Preview.js b/frontend/packages/client/src/components/ProposalCreate/Preview.js index 1b4938c88..f30e8ff14 100644 --- a/frontend/packages/client/src/components/ProposalCreate/Preview.js +++ b/frontend/packages/client/src/components/ProposalCreate/Preview.js @@ -33,6 +33,7 @@ const Preview = ({ stepsData }) => { })) : null, }; + console.log(proposal.choices); return (

{name}

diff --git a/frontend/packages/client/src/components/ProposalCreate/StepThree/TimeIntervals.js b/frontend/packages/client/src/components/ProposalCreate/StepThree/TimeIntervals.js index a3b6bd239..ad112458e 100644 --- a/frontend/packages/client/src/components/ProposalCreate/StepThree/TimeIntervals.js +++ b/frontend/packages/client/src/components/ProposalCreate/StepThree/TimeIntervals.js @@ -47,7 +47,7 @@ const getStartTimeInterval = (startDateIsToday) => { const getStartTimeIntervalWithDelay = (date, startDateIsToday) => { if (startDateIsToday) { - return new Date(Date.now() + 60 * 60 * 1000); + return new Date(Date.now() + 10 * 60 * 1000); } const startDateIsTomorrow = date ? isTomorrow(date) : false; diff --git a/frontend/packages/client/src/components/ProposalCreate/StepTwo/BasicVoteExample.js b/frontend/packages/client/src/components/ProposalCreate/StepTwo/BasicVoteExample.js new file mode 100644 index 000000000..6a351aa4d --- /dev/null +++ b/frontend/packages/client/src/components/ProposalCreate/StepTwo/BasicVoteExample.js @@ -0,0 +1,23 @@ +export default function BasicVoteExample() { + return ( + <> + {['For', 'Against', 'Abstain'].map((text, index) => ( +
+
+ {index === 0 ? ( + + ) : null} +
+ {text} +
+ ))} + + ); +} diff --git a/frontend/packages/client/src/components/ProposalCreate/StepTwo/ChoiceOptionCreator.js b/frontend/packages/client/src/components/ProposalCreate/StepTwo/ChoiceOptionCreator.js index 4cb8bde77..780a3cfd1 100644 --- a/frontend/packages/client/src/components/ProposalCreate/StepTwo/ChoiceOptionCreator.js +++ b/frontend/packages/client/src/components/ProposalCreate/StepTwo/ChoiceOptionCreator.js @@ -20,7 +20,7 @@ export default function ChoiceOptionCreator({ setValue('tabOption', option); }; - if (voteType === 'ranked-choice') { + if (voteType === 'ranked-choice' || voteType === 'basic') { setTab('text-based'); } diff --git a/frontend/packages/client/src/components/ProposalCreate/StepTwo/TextBasedChoices.js b/frontend/packages/client/src/components/ProposalCreate/StepTwo/TextBasedChoices.js index 390f7956c..bd973abe4 100644 --- a/frontend/packages/client/src/components/ProposalCreate/StepTwo/TextBasedChoices.js +++ b/frontend/packages/client/src/components/ProposalCreate/StepTwo/TextBasedChoices.js @@ -17,7 +17,7 @@ const TextBasedChoices = ({ remove, } = useFieldArray({ control, - name: 'choices', + name: fieldName, focusAppend: true, }); diff --git a/frontend/packages/client/src/components/ProposalCreate/StepTwo/VotingSelector.js b/frontend/packages/client/src/components/ProposalCreate/StepTwo/VotingSelector.js index 4f6aab81c..ffb25645c 100644 --- a/frontend/packages/client/src/components/ProposalCreate/StepTwo/VotingSelector.js +++ b/frontend/packages/client/src/components/ProposalCreate/StepTwo/VotingSelector.js @@ -1,10 +1,33 @@ import { Card } from 'components/Card'; +import Input from 'components/common/Input'; import { Box, Heading } from '@chakra-ui/react'; import { Text } from '@chakra-ui/react'; +import BasicVoteExample from './BasicVoteExample'; import ChoiceOptionCreator from './ChoiceOptionCreator'; import RankedVoteExample from './RankedVoteExample'; import SingleVoteExample from './SingleVoteExample'; +const getChoicesDescription = (voteType) => { + return voteType === 'single-choice' ? ( + + Provide the specific options you’d like to cast votes for. Use Text-based + presentation for choices that are described in words. Use Visual for + side-by-side visual options represented by images. + + ) : ( + <> + + Provide the specific options you’d like to cast votes for. Ranked Choice + Voting currently only supports Text-based presentation for choices that + are described in words. + + + All choices will be randomized for voters + + + ); +}; + export default function VotingSelector({ voteType, setValue, @@ -72,6 +95,37 @@ export default function VotingSelector({
+ + setValue('voteType', 'basic', { shouldValidate: true }) + } + className={voteType === 'basic' ? 'border-grey' : ''} + > +
+
+ +
+
+
+
Basic Voting
+

+ Voters vote on one option and are given three options to vote + on. (For, Against or Abstain) +

+
+
+ +
+
- - Choices * - - {voteType === 'single-choice' ? ( - - Provide the specific options you’d like to cast votes for. Use - Text-based presentation for choices that are described in words. Use - Visual for side-by-side visual options represented by images. - + {voteType !== 'basic' ? ( + <> + + Choices * + + {getChoicesDescription(voteType)} + + ) : ( <> + + How many votes are needed to reach quorum?{' '} + * + - Provide the specific options you’d like to cast votes for. Ranked - Choice Voting currently only supports Text-based presentation for - choices that are described in words. - - - All choices will be randomized for voters + Set a number of total votes required to make the results of the + proposal valid. +
+ + Learn More +
+ )} -
); diff --git a/frontend/packages/client/src/hooks/useCommunityProposalsWithVotes.js b/frontend/packages/client/src/hooks/useCommunityProposalsWithVotes.js index bc2a8f540..1002689a9 100644 --- a/frontend/packages/client/src/hooks/useCommunityProposalsWithVotes.js +++ b/frontend/packages/client/src/hooks/useCommunityProposalsWithVotes.js @@ -39,7 +39,7 @@ export default function useCommunityProposalsWithVotes({ const userVotes = await fetchProposalUserVotes({ addr, proposalIds }); const mergedMapResults = assign( {}, - ...proposalIds.map((id) => ({ [id]: null })), + ...(proposalIds ?? []).map((id) => ({ [id]: null })), ...(userVotes?.data ?? []).map(({ proposalId, choices }) => ({ [proposalId]: choices, })) diff --git a/frontend/packages/client/src/pages/Proposal.js b/frontend/packages/client/src/pages/Proposal.js index 8821ec359..c39fc6234 100644 --- a/frontend/packages/client/src/pages/Proposal.js +++ b/frontend/packages/client/src/pages/Proposal.js @@ -325,7 +325,7 @@ export default function ProposalPage() { )} - {confirmingVote && castingVote && String(castVote) && ( + {confirmingVote && castingVote && castVote !== null && (
@@ -412,7 +412,8 @@ export default function ProposalPage() { held in Balancer's liquidity pools.
)} - {proposal.voteType === 'single-choice' ? ( + {proposal.voteType === 'single-choice' || + proposal.voteType === 'basic' ? ( )} - {proposal.voteType === 'single-choice' ? ( + {proposal.voteType === 'single-choice' || + proposal.voteType === 'basic' ? ( { startDateAndTime.setHours(startTime.getHours(), startTime.getMinutes()); - const dif = (startDateAndTime - dateNow) / (60 * 60 * 1000); + // difference for PROD is 10 min + const dif = (startDateAndTime - dateNow) / (10 * 60 * 1000); return HAS_DELAY_ON_START_TIME ? dif > 1 : true; };