From 2c0502cd207ff0a807ceb761ab46e6458654ecb7 Mon Sep 17 00:00:00 2001 From: "ildar.timerbaev" Date: Thu, 6 Jun 2024 21:48:17 +0600 Subject: [PATCH 1/4] Added multiple polls voting support --- .../polls/api/get-poll-details-query.ts | 1 + .../features/polls/api/sign-poll-vote.ts | 56 +++++++++++-------- .../components/poll-option-with-results.tsx | 6 +- .../features/polls/components/poll-option.tsx | 15 ++--- .../features/polls/components/poll-widget.tsx | 22 +++++--- .../polls/components/polls-creation.tsx | 32 +++++++++++ .../hooks/use-polls-creation-management.ts | 3 + src/common/i18n/locales/en-US.json | 6 +- 8 files changed, 98 insertions(+), 43 deletions(-) diff --git a/src/common/features/polls/api/get-poll-details-query.ts b/src/common/features/polls/api/get-poll-details-query.ts index 95d0bf0fc71..facf6fca72b 100644 --- a/src/common/features/polls/api/get-poll-details-query.ts +++ b/src/common/features/polls/api/get-poll-details-query.ts @@ -22,6 +22,7 @@ export interface GetPollDetailsQueryResponse { poll_voters?: { name: string; choice_num: number }[]; post_body: string; post_title: string; + max_choices_voted?: number; preferred_interpretation: string; protocol_version: number; question: string; diff --git a/src/common/features/polls/api/sign-poll-vote.ts b/src/common/features/polls/api/sign-poll-vote.ts index 3fd4b48a8e5..c8221bb1841 100644 --- a/src/common/features/polls/api/sign-poll-vote.ts +++ b/src/common/features/polls/api/sign-poll-vote.ts @@ -12,14 +12,16 @@ export function useSignPollVoteByKey(poll: ReturnType { + mutationFn: async ({ choices }: { choices: Set }) => { if (!poll || !activeUser) { error(_t("polls.not-found")); return; } - const choiceNum = poll.poll_choices?.find((pc) => pc.choice_text === choice)?.choice_num; - if (typeof choiceNum !== "number") { + const choiceNums = poll.poll_choices + ?.filter((pc) => choices.has(pc.choice_text)) + ?.map((i) => i.choice_num); + if (choiceNums.length === 0) { error(_t("polls.not-found")); return; } @@ -27,10 +29,10 @@ export function useSignPollVoteByKey(poll: ReturnType queryClient.setQueryData["data"]>( @@ -40,14 +42,18 @@ export function useSignPollVoteByKey(poll: ReturnType pv.name === activeUser!!.username); - const previousUserChoice = data.poll_choices?.find( - (pc) => existingVote?.choice_num === pc.choice_num + const existingVotes = data.poll_voters?.filter((pv) => pv.name === activeUser!!.username); + const previousUserChoices = data.poll_choices?.filter((pc) => + existingVotes?.some((ev) => ev.choice_num === pc.choice_num) ); - const choice = data.poll_choices?.find((pc) => pc.choice_num === resp.choiceNum)!!; + const choices = data.poll_choices?.filter((pc) => !!resp.choiceNums[pc.choice_num]); const notTouchedChoices = data.poll_choices?.filter( - (pc) => ![previousUserChoice?.choice_num, choice?.choice_num].includes(pc.choice_num) + (pc) => + ![ + ...previousUserChoices?.map((puc) => puc.choice_num), + choices?.map((c) => c.choice_num) + ].includes(pc.choice_num) ); const otherVoters = data.poll_voters?.filter((pv) => pv.name !== activeUser!!.username) ?? []; @@ -56,32 +62,34 @@ export function useSignPollVoteByKey(poll: ReturnType choices.every((c) => pv.choice_text !== c.choice_text)) + .map((pv) => ({ + ...pv, + votes: { + total_votes: (pv?.votes?.total_votes ?? 0) - 1 } - : undefined, - { + })) ?? []), + ...choices.map((choice) => ({ ...choice, votes: { total_votes: (choice?.votes?.total_votes ?? 0) + - (previousUserChoice?.choice_text !== choice.choice_text ? 1 : 0) + (previousUserChoices.every((pv) => pv.choice_text !== choice.choice_text) + ? 1 + : 0) } - } + })) ].filter((el) => !!el), poll_voters: [ ...otherVoters, - { name: activeUser?.username, choice_num: resp.choiceNum } + ...resp.choiceNums.map((num) => ({ name: activeUser?.username, choice_num: num })) ], poll_stats: { ...data.poll_stats, - total_voting_accounts_num: existingVote - ? data.poll_stats.total_voting_accounts_num - : data.poll_stats.total_voting_accounts_num + 1 + total_voting_accounts_num: + data.poll_stats.total_voting_accounts_num + + (choices.length - (existingVotes?.length ?? 0)) } } as ReturnType["data"]; } diff --git a/src/common/features/polls/components/poll-option-with-results.tsx b/src/common/features/polls/components/poll-option-with-results.tsx index 557f46bc259..bc93aa09156 100644 --- a/src/common/features/polls/components/poll-option-with-results.tsx +++ b/src/common/features/polls/components/poll-option-with-results.tsx @@ -7,13 +7,13 @@ import { PollSnapshot } from "./polls-creation"; import { _t } from "../../../i18n"; export interface Props { - activeChoice?: string; + activeChoices: Set; choice: string; entry?: Entry; interpretation: PollSnapshot["interpretation"]; } -export function PollOptionWithResults({ choice, activeChoice, entry, interpretation }: Props) { +export function PollOptionWithResults({ choice, activeChoices, entry, interpretation }: Props) { const pollDetails = useGetPollDetailsQuery(entry); const votesCount = useMemo( @@ -61,7 +61,7 @@ export function PollOptionWithResults({ choice, activeChoice, entry, interpretat width: `${progress}%` }} /> - {activeChoice === choice && } + {activeChoices.has(choice) && }
{choice} diff --git a/src/common/features/polls/components/poll-option.tsx b/src/common/features/polls/components/poll-option.tsx index 03f1d13e4d1..318cb5d31c9 100644 --- a/src/common/features/polls/components/poll-option.tsx +++ b/src/common/features/polls/components/poll-option.tsx @@ -11,26 +11,27 @@ export function PollCheck({ checked }: { checked: boolean }) { } export interface Props { - activeChoice?: string; + activeChoices: Set; choice: string; - setActiveChoice: (choice?: string) => void; + addActiveChoice: (choice: string) => void; + removeActiveChoice: (choice: string) => void; } -export function PollOption({ activeChoice, choice, setActiveChoice }: Props) { +export function PollOption({ activeChoices, choice, addActiveChoice, removeActiveChoice }: Props) { return (
- activeChoice === choice ? setActiveChoice(undefined) : setActiveChoice(choice) + activeChoices.has(choice) ? removeActiveChoice(choice) : addActiveChoice(choice) } > - + {choice}
); diff --git a/src/common/features/polls/components/poll-widget.tsx b/src/common/features/polls/components/poll-widget.tsx index 30b0643813e..61d267f690e 100644 --- a/src/common/features/polls/components/poll-widget.tsx +++ b/src/common/features/polls/components/poll-widget.tsx @@ -13,6 +13,7 @@ import { format, isBefore } from "date-fns"; import useLocalStorage from "react-use/lib/useLocalStorage"; import { PREFIX } from "../../../util/local-storage"; import { FormControl } from "@ui/input"; +import { useSet } from "react-use"; interface Props { poll: PollSnapshot; @@ -31,7 +32,7 @@ export function PollWidget({ poll, isReadOnly, entry }: Props) { const { mutateAsync: vote, isLoading: isVoting } = useSignPollVoteByKey(pollDetails.data); - const [activeChoice, setActiveChoice] = useState(); + const [activeChoices, { add: addActiveChoice, remove: removeActiveChoice }] = useSet(); const [resultsMode, setResultsMode] = useState(false); const [isVotedAlready, setIsVotedAlready] = useState(false); const [showEndDate, setShowEndDate] = useLocalStorage(PREFIX + "_plls_set", false); @@ -63,7 +64,9 @@ export function PollWidget({ poll, isReadOnly, entry }: Props) { const choice = pollDetails.data?.poll_choices.find( (pc) => pc.choice_num === activeUserVote.choice_num ); - setActiveChoice(choice?.choice_text); + if (choice) { + addActiveChoice(choice?.choice_text); + } } }, [activeUserVote, pollDetails.data]); @@ -132,14 +135,19 @@ export function PollWidget({ poll, isReadOnly, entry }: Props) { key={choice} entry={entry} choice={choice} - activeChoice={activeChoice} + activeChoices={activeChoices} /> ) : ( { + if (activeChoices.size < (pollDetails.data?.max_choices_voted ?? 1)) { + addActiveChoice(v); + } + }} + removeActiveChoice={removeActiveChoice} + activeChoices={activeChoices} /> ) )} @@ -165,14 +173,14 @@ export function PollWidget({ poll, isReadOnly, entry }: Props) {
{showVote && ( + ("number_of_votes"); const [voteChange, setVoteChange] = useLocalStorage(PREFIX + "_plls_vc", true); const [hideVotes, setHideVotes] = useLocalStorage(PREFIX + "_plls_cs", false); + const [maxChoicesVoted, setMaxChoicesVoted] = useLocalStorage(PREFIX + "_plls_mcv", 1); const hasEmptyOrDuplicatedChoices = useMemo(() => { if (!choices || choices.length <= 1) { @@ -83,6 +84,8 @@ export function usePollsCreationManagement(poll?: PollSnapshot) { isExpiredEndDate, endTime, setEndTime, + maxChoicesVoted, + setMaxChoicesVoted, clearAll: () => { clearTitle(); clearEndDate(); diff --git a/src/common/i18n/locales/en-US.json b/src/common/i18n/locales/en-US.json index 05477387bb5..3c57d2c1ab4 100644 --- a/src/common/i18n/locales/en-US.json +++ b/src/common/i18n/locales/en-US.json @@ -91,7 +91,8 @@ "restore": "Restore", "success": "Success", "error": "Error", - "reset-form": "Reset form" + "reset-form": "Reset form", + "all": "All" }, "confirm": { "title": "Are you sure?", @@ -2459,6 +2460,7 @@ "expired-date": "End date should be in present or future", "interpretation": "Interpretation", "creating-in-progress": "Creating in progress...", - "invalid-time": "Invalid time format. Use HH:MM" + "invalid-time": "Invalid time format. Use HH:MM", + "max-choices-voted": "Max choices voted by user" } } From bffa20f0d2f40e67df812caa3f95f32834dad955 Mon Sep 17 00:00:00 2001 From: "ildar.timerbaev" Date: Fri, 7 Jun 2024 22:13:08 +0600 Subject: [PATCH 2/4] Updated poll multi-choice votes counter --- src/common/features/polls/api/index.ts | 1 + .../polls/api/polls-votes-management.ts | 65 +++++++++++++++++++ .../features/polls/api/sign-poll-vote.ts | 52 +-------------- .../features/polls/components/poll-widget.tsx | 7 +- src/common/i18n/locales/en-US.json | 3 +- 5 files changed, 76 insertions(+), 52 deletions(-) create mode 100644 src/common/features/polls/api/polls-votes-management.ts diff --git a/src/common/features/polls/api/index.ts b/src/common/features/polls/api/index.ts index d6350fc501f..c0eead5d4d4 100644 --- a/src/common/features/polls/api/index.ts +++ b/src/common/features/polls/api/index.ts @@ -1,2 +1,3 @@ export * from "./get-poll-details-query"; export * from "./sign-poll-vote"; +export * from "./polls-votes-management"; diff --git a/src/common/features/polls/api/polls-votes-management.ts b/src/common/features/polls/api/polls-votes-management.ts new file mode 100644 index 00000000000..e2f4afc1a7a --- /dev/null +++ b/src/common/features/polls/api/polls-votes-management.ts @@ -0,0 +1,65 @@ +import { GetPollDetailsQueryResponse } from "./get-poll-details-query"; +import { ActiveUser } from "../../../store/active-user/types"; + +export namespace PollsVotesManagement { + export function processVoting( + activeUser: ActiveUser | null, + data: GetPollDetailsQueryResponse, + choiceNums: number[] + ): GetPollDetailsQueryResponse { + const existingVotes = data.poll_voters?.filter((pv) => pv.name === activeUser!!.username); + const existingUserChoices = data.poll_choices?.filter( + (pc) => !!existingVotes?.some((ev) => ev.choice_num === pc.choice_num) + ); + const currentUserChoices = data.poll_choices?.filter((pc) => + choiceNums.includes(pc.choice_num) + ); + + const notTouchedChoices = data.poll_choices?.filter( + (pc) => + ![ + ...existingUserChoices?.map((puc) => puc.choice_num), + ...currentUserChoices?.map((c) => c.choice_num) + ].includes(pc.choice_num) + ); + const nonActiveUserVotes = + data.poll_voters?.filter((pv) => pv.name !== activeUser!!.username) ?? []; + + return { + ...data, + poll_choices: [ + ...notTouchedChoices, + + ...(existingUserChoices + .filter((choice) => currentUserChoices.every((c) => choice.choice_text !== c.choice_text)) + .map((pv) => ({ + ...pv, + votes: { + hive_hp_incl_proxied: pv.votes?.hive_hp_incl_proxied!, + total_votes: (pv?.votes?.total_votes ?? 0) - 1 + } + })) ?? []), + + ...currentUserChoices.map((choice) => ({ + ...choice, + votes: { + hive_hp_incl_proxied: choice.votes?.hive_hp_incl_proxied!, + total_votes: + (choice?.votes?.total_votes ?? 0) + + (existingUserChoices.every((pv) => pv.choice_text !== choice.choice_text) ? 1 : 0) + } + })) + ], + poll_voters: [ + ...nonActiveUserVotes, + ...choiceNums.map((num) => ({ name: activeUser!.username, choice_num: num })) + ], + poll_stats: { + ...data.poll_stats, + total_voting_accounts_num: + data.poll_stats.total_voting_accounts_num + + (currentUserChoices.length - (existingVotes?.length ?? 0)) + } + }; + } +} diff --git a/src/common/features/polls/api/sign-poll-vote.ts b/src/common/features/polls/api/sign-poll-vote.ts index c8221bb1841..0700d81db65 100644 --- a/src/common/features/polls/api/sign-poll-vote.ts +++ b/src/common/features/polls/api/sign-poll-vote.ts @@ -5,6 +5,7 @@ import { _t } from "../../../i18n"; import { useMappedStore } from "../../../store/use-mapped-store"; import { broadcastPostingJSON } from "../../../api/operations"; import { QueryIdentifiers } from "../../../core"; +import { PollsVotesManagement } from "./polls-votes-management"; export function useSignPollVoteByKey(poll: ReturnType["data"]) { const { activeUser } = useMappedStore(); @@ -42,56 +43,7 @@ export function useSignPollVoteByKey(poll: ReturnType pv.name === activeUser!!.username); - const previousUserChoices = data.poll_choices?.filter((pc) => - existingVotes?.some((ev) => ev.choice_num === pc.choice_num) - ); - const choices = data.poll_choices?.filter((pc) => !!resp.choiceNums[pc.choice_num]); - - const notTouchedChoices = data.poll_choices?.filter( - (pc) => - ![ - ...previousUserChoices?.map((puc) => puc.choice_num), - choices?.map((c) => c.choice_num) - ].includes(pc.choice_num) - ); - const otherVoters = - data.poll_voters?.filter((pv) => pv.name !== activeUser!!.username) ?? []; - - return { - ...data, - poll_choices: [ - ...notTouchedChoices, - ...(previousUserChoices - .filter((pv) => choices.every((c) => pv.choice_text !== c.choice_text)) - .map((pv) => ({ - ...pv, - votes: { - total_votes: (pv?.votes?.total_votes ?? 0) - 1 - } - })) ?? []), - ...choices.map((choice) => ({ - ...choice, - votes: { - total_votes: - (choice?.votes?.total_votes ?? 0) + - (previousUserChoices.every((pv) => pv.choice_text !== choice.choice_text) - ? 1 - : 0) - } - })) - ].filter((el) => !!el), - poll_voters: [ - ...otherVoters, - ...resp.choiceNums.map((num) => ({ name: activeUser?.username, choice_num: num })) - ], - poll_stats: { - ...data.poll_stats, - total_voting_accounts_num: - data.poll_stats.total_voting_accounts_num + - (choices.length - (existingVotes?.length ?? 0)) - } - } as ReturnType["data"]; + return PollsVotesManagement.processVoting(activeUser, data, resp.choiceNums); } ) }); diff --git a/src/common/features/polls/components/poll-widget.tsx b/src/common/features/polls/components/poll-widget.tsx index 61d267f690e..09b31484bb9 100644 --- a/src/common/features/polls/components/poll-widget.tsx +++ b/src/common/features/polls/components/poll-widget.tsx @@ -127,6 +127,11 @@ export function PollWidget({ poll, isReadOnly, entry }: Props) { {_t("polls.account-age-hint", { n: poll.filters.accountAge })} )} + {!resultsMode && ( +
+ {_t("polls.max-votes-hint", { n: poll.maxChoicesVoted ?? 1 })} +
+ )}
{poll.choices.map((choice) => resultsMode ? ( @@ -173,7 +178,7 @@ export function PollWidget({ poll, isReadOnly, entry }: Props) {
{showVote && (