mirror of https://gitlab.com/ecentrics/concordia
				
				
			
				 17 changed files with 633 additions and 27 deletions
			
			
		| @ -0,0 +1,23 @@ | |||||
|  | import React from 'react'; | ||||
|  | import { Container, Header, Icon } from 'semantic-ui-react'; | ||||
|  | import { useTranslation } from 'react-i18next'; | ||||
|  | 
 | ||||
|  | const PollDataInvalid = () => { | ||||
|  |   const { t } = useTranslation(); | ||||
|  | 
 | ||||
|  |   return ( | ||||
|  |       <Container id="topic-poll-data-invalid-container" textAlign="center"> | ||||
|  |           <Header as="h3" icon textAlign="center"> | ||||
|  |               <Icon name="warning sign" color="red" /> | ||||
|  |               <Header.Content> | ||||
|  |                   {t('topic.poll.invalid.data.header')} | ||||
|  |                   <Header.Subheader> | ||||
|  |                       {t('topic.poll.invalid.data.sub.header')} | ||||
|  |                   </Header.Subheader> | ||||
|  |               </Header.Content> | ||||
|  |           </Header> | ||||
|  |       </Container> | ||||
|  |   ); | ||||
|  | }; | ||||
|  | 
 | ||||
|  | export default PollDataInvalid; | ||||
| @ -0,0 +1,79 @@ | |||||
|  | import React, { useMemo } from 'react'; | ||||
|  | import Chart from 'react-apexcharts'; | ||||
|  | import { Grid, Header } from 'semantic-ui-react'; | ||||
|  | import PropTypes from 'prop-types'; | ||||
|  | import { useTranslation } from 'react-i18next'; | ||||
|  | import { CASTED_OPTION_COLOR, DEFAULT_OPTION_COLOR } from '../../../constants/polls/PollGraph'; | ||||
|  | 
 | ||||
|  | const PollGraph = (props) => { | ||||
|  |   const { | ||||
|  |     pollOptions, voteCounts, hasUserVoted, selectedOptionIndex, | ||||
|  |   } = props; | ||||
|  |   const { t } = useTranslation(); | ||||
|  | 
 | ||||
|  |   const chartOptions = useMemo(() => ({ | ||||
|  |     chart: { | ||||
|  |       id: 'topic-poll', | ||||
|  |     }, | ||||
|  |     plotOptions: { | ||||
|  |       bar: { | ||||
|  |         horizontal: true, | ||||
|  |       }, | ||||
|  |     }, | ||||
|  |     colors: [ | ||||
|  |       (value) => { | ||||
|  |         if (hasUserVoted && value.dataPointIndex === selectedOptionIndex) { | ||||
|  |           return CASTED_OPTION_COLOR; | ||||
|  |         } | ||||
|  |         return DEFAULT_OPTION_COLOR; | ||||
|  |       }, | ||||
|  |     ], | ||||
|  |     xaxis: { | ||||
|  |       categories: pollOptions, | ||||
|  |     }, | ||||
|  |   }), [hasUserVoted, pollOptions, selectedOptionIndex]); | ||||
|  | 
 | ||||
|  |   const chartSeries = useMemo(() => [{ | ||||
|  |     name: 'votes', | ||||
|  |     data: voteCounts, | ||||
|  |   }], [voteCounts]); | ||||
|  | 
 | ||||
|  |   return ( | ||||
|  |       <Grid columns="equal"> | ||||
|  |           <Grid.Row> | ||||
|  |               <Grid.Column /> | ||||
|  |               <Grid.Column width={8}> | ||||
|  |                   <Chart | ||||
|  |                     options={chartOptions} | ||||
|  |                     series={chartSeries} | ||||
|  |                     type="bar" | ||||
|  |                   /> | ||||
|  |               </Grid.Column> | ||||
|  |               <Grid.Column /> | ||||
|  |           </Grid.Row> | ||||
|  |           <Grid.Row> | ||||
|  |               <Grid.Column textAlign="center"> | ||||
|  |                   <Header as="h4"> | ||||
|  |                       {t('topic.poll.tab.results.votes.count', { | ||||
|  |                         totalVotes: voteCounts.reduce((accumulator, voteCount) => accumulator + voteCount, 0), | ||||
|  |                       })} | ||||
|  |                   </Header> | ||||
|  |               </Grid.Column> | ||||
|  |           </Grid.Row> | ||||
|  |       </Grid> | ||||
|  |   ); | ||||
|  | }; | ||||
|  | 
 | ||||
|  | PollGraph.defaultProps = { | ||||
|  |   hasUserVoted: false, | ||||
|  |   selectedOptionIndex: '', | ||||
|  | }; | ||||
|  | 
 | ||||
|  | PollGraph.propTypes = { | ||||
|  |   pollOptions: PropTypes.arrayOf(PropTypes.string).isRequired, | ||||
|  |   voteCounts: PropTypes.arrayOf(PropTypes.number).isRequired, | ||||
|  |   hasUserVoted: PropTypes.bool, | ||||
|  |   selectedOptionIndex: PropTypes.string, | ||||
|  | }; | ||||
|  | 
 | ||||
|  | export default PollGraph; | ||||
| @ -0,0 +1,26 @@ | |||||
|  | import React from 'react'; | ||||
|  | import { Container, Header, Icon } from 'semantic-ui-react'; | ||||
|  | import { Link } from 'react-router-dom'; | ||||
|  | import { useTranslation } from 'react-i18next'; | ||||
|  | 
 | ||||
|  | const PollGuestView = () => { | ||||
|  |   const { t } = useTranslation(); | ||||
|  | 
 | ||||
|  |   return ( | ||||
|  |       <Container id="topic-poll-guest-view-container" textAlign="center"> | ||||
|  |           <Header as="h3" icon textAlign="center"> | ||||
|  |               <Icon name="signup" /> | ||||
|  |               <Header.Content> | ||||
|  |                   {t('topic.poll.guest.header')} | ||||
|  |                   <Header.Subheader> | ||||
|  |                       {t('topic.poll.guest.sub.header.pre')} | ||||
|  |                       <Link to="/auth/register">{t('topic.poll.guest.sub.header.link')}</Link> | ||||
|  |                       {t('topic.poll.guest.sub.header.post')} | ||||
|  |                   </Header.Subheader> | ||||
|  |               </Header.Content> | ||||
|  |           </Header> | ||||
|  |       </Container> | ||||
|  |   ); | ||||
|  | }; | ||||
|  | 
 | ||||
|  | export default PollGuestView; | ||||
| @ -0,0 +1,64 @@ | |||||
|  | import React, { useState } from 'react'; | ||||
|  | import PropTypes from 'prop-types'; | ||||
|  | import { Form } from 'semantic-ui-react'; | ||||
|  | import { useTranslation } from 'react-i18next'; | ||||
|  | import { VOTING_CONTRACT } from 'concordia-shared/src/constants/contracts/ContractNames'; | ||||
|  | import { drizzle } from '../../../redux/store'; | ||||
|  | 
 | ||||
|  | const { contracts: { [VOTING_CONTRACT]: { methods: { vote } } } } = drizzle; | ||||
|  | 
 | ||||
|  | const PollVote = (props) => { | ||||
|  |   const { | ||||
|  |     topicId, account, pollOptions, enableVoteChanges, hasUserVoted, userVoteIndex, | ||||
|  |   } = props; | ||||
|  |   const [selectedOptionIndex, setSelectedOptionIndex] = useState(userVoteIndex); | ||||
|  |   const [voting, setVoting] = useState(''); | ||||
|  |   const { t } = useTranslation(); | ||||
|  | 
 | ||||
|  |   const onOptionSelected = (e, { value }) => { | ||||
|  |     setSelectedOptionIndex(value); | ||||
|  |   }; | ||||
|  | 
 | ||||
|  |   const onCastVote = () => { | ||||
|  |     setVoting(true); | ||||
|  |     vote.cacheSend(...[topicId, selectedOptionIndex + 1], { from: account }); | ||||
|  |   }; | ||||
|  | 
 | ||||
|  |   return ( | ||||
|  |       <Form onSubmit={onCastVote}> | ||||
|  |           <Form.Group grouped> | ||||
|  |               <label htmlFor="poll">{t('topic.poll.tab.vote.form.radio.label')}</label> | ||||
|  |               {pollOptions.map((pollOption, index) => ( | ||||
|  |                   <Form.Radio | ||||
|  |                     key={pollOption} | ||||
|  |                     label={pollOption} | ||||
|  |                     value={index} | ||||
|  |                     checked={index === selectedOptionIndex} | ||||
|  |                     disabled={hasUserVoted && !enableVoteChanges && index !== selectedOptionIndex} | ||||
|  |                     onChange={onOptionSelected} | ||||
|  |                   /> | ||||
|  |               ))} | ||||
|  |           </Form.Group> | ||||
|  |           <Form.Button | ||||
|  |             type="submit" | ||||
|  |             disabled={voting || (hasUserVoted && !enableVoteChanges) || (selectedOptionIndex === userVoteIndex)} | ||||
|  |           > | ||||
|  |               {t('topic.poll.tab.vote.form.button.submit')} | ||||
|  |           </Form.Button> | ||||
|  |       </Form> | ||||
|  |   ); | ||||
|  | }; | ||||
|  | 
 | ||||
|  | PollVote.defaultProps = { | ||||
|  |   userVoteIndex: -1, | ||||
|  | }; | ||||
|  | 
 | ||||
|  | PollVote.propTypes = { | ||||
|  |   topicId: PropTypes.number.isRequired, | ||||
|  |   pollOptions: PropTypes.arrayOf(PropTypes.string).isRequired, | ||||
|  |   enableVoteChanges: PropTypes.bool.isRequired, | ||||
|  |   hasUserVoted: PropTypes.bool.isRequired, | ||||
|  |   userVoteIndex: PropTypes.number, | ||||
|  | }; | ||||
|  | 
 | ||||
|  | export default PollVote; | ||||
| @ -0,0 +1,184 @@ | |||||
|  | import React, { useEffect, useMemo, useState } from 'react'; | ||||
|  | import { useDispatch, useSelector } from 'react-redux'; | ||||
|  | import { VOTING_CONTRACT } from 'concordia-shared/src/constants/contracts/ContractNames'; | ||||
|  | import { | ||||
|  |   Container, Header, Icon, Tab, | ||||
|  | } from 'semantic-ui-react'; | ||||
|  | import { useTranslation } from 'react-i18next'; | ||||
|  | import PropTypes from 'prop-types'; | ||||
|  | import { POLLS_DATABASE } from 'concordia-shared/src/constants/orbit/OrbitDatabases'; | ||||
|  | import { breeze, drizzle } from '../../redux/store'; | ||||
|  | import PollGraph from './PollGraph'; | ||||
|  | import CustomLoadingTabPane from '../CustomLoadingTabPane'; | ||||
|  | import { GRAPH_TAB, VOTE_TAB } from '../../constants/polls/PollTabs'; | ||||
|  | import PollVote from './PollVote'; | ||||
|  | import { FETCH_USER_DATABASE } from '../../redux/actions/peerDbReplicationActions'; | ||||
|  | import { generatePollHash } from '../../utils/hashUtils'; | ||||
|  | import { POLL_OPTIONS, POLL_QUESTION } from '../../constants/orbit/PollsDatabaseKeys'; | ||||
|  | import PollDataInvalid from './PollDataInvalid'; | ||||
|  | import PollGuestView from './PollGuestView'; | ||||
|  | 
 | ||||
|  | const { contracts: { [VOTING_CONTRACT]: { methods: { getPoll: { cacheCall: getPollChainData } } } } } = drizzle; | ||||
|  | const { orbit } = breeze; | ||||
|  | 
 | ||||
|  | const PollView = (props) => { | ||||
|  |   const { topicId } = props; | ||||
|  |   const userAddress = useSelector((state) => state.user.address); | ||||
|  |   const hasSignedUp = useSelector((state) => state.user.hasSignedUp); | ||||
|  |   const getPollResults = useSelector((state) => state.contracts[VOTING_CONTRACT].getPoll); | ||||
|  |   const polls = useSelector((state) => state.orbitData.polls); | ||||
|  |   const [getPollCallHash, setGetPollCallHash] = useState(null); | ||||
|  |   const [pollHash, setPollHash] = useState(''); | ||||
|  |   const [pollChangeVoteEnabled, setPollChangeVoteEnabled] = useState(false); | ||||
|  |   const [pollOptions, setPollOptions] = useState([]); | ||||
|  |   const [voteCounts, setVoteCounts] = useState([]); | ||||
|  |   const [voters, setVoters] = useState([]); | ||||
|  |   const [pollHashValid, setPollHashValid] = useState(false); | ||||
|  |   const [pollQuestion, setPollQuestion] = useState(''); | ||||
|  |   const [chainDataLoading, setChainDataLoading] = useState(true); | ||||
|  |   const [orbitDataLoading, setOrbitDataLoading] = useState(true); | ||||
|  |   const dispatch = useDispatch(); | ||||
|  |   const { t } = useTranslation(); | ||||
|  | 
 | ||||
|  |   useEffect(() => { | ||||
|  |     if (!getPollCallHash) { | ||||
|  |       setGetPollCallHash(getPollChainData(topicId)); | ||||
|  |     } | ||||
|  |   }, [getPollCallHash, topicId]); | ||||
|  | 
 | ||||
|  |   useEffect(() => { | ||||
|  |     dispatch({ | ||||
|  |       type: FETCH_USER_DATABASE, | ||||
|  |       orbit, | ||||
|  |       dbName: POLLS_DATABASE, | ||||
|  |       userAddress, | ||||
|  |     }); | ||||
|  |   }, [dispatch, userAddress]); | ||||
|  | 
 | ||||
|  |   useEffect(() => { | ||||
|  |     if (getPollCallHash && getPollResults && getPollResults[getPollCallHash]) { | ||||
|  |       setPollHash(getPollResults[getPollCallHash].value[1]); | ||||
|  |       setPollChangeVoteEnabled(getPollResults[getPollCallHash].value[2]); | ||||
|  |       setVoteCounts(getPollResults[getPollCallHash].value[4].map((voteCount) => parseInt(voteCount, 10))); | ||||
|  | 
 | ||||
|  |       const cumulativeSum = getPollResults[getPollCallHash].value[4] | ||||
|  |         .map((voteCount) => parseInt(voteCount, 10)) | ||||
|  |         .reduce((accumulator, voteCount) => (accumulator.length === 0 | ||||
|  |           ? [voteCount] | ||||
|  |           : [...accumulator, accumulator[accumulator.length - 1] + voteCount]), []); | ||||
|  | 
 | ||||
|  |       setVoters(cumulativeSum | ||||
|  |         .map((subArrayEnd, index) => getPollResults[getPollCallHash].value[5] | ||||
|  |           .slice(index > 0 ? cumulativeSum[index - 1] : 0, | ||||
|  |             subArrayEnd))); | ||||
|  | 
 | ||||
|  |       setChainDataLoading(false); | ||||
|  |     } | ||||
|  |   }, [getPollCallHash, getPollResults]); | ||||
|  | 
 | ||||
|  |   useEffect(() => { | ||||
|  |     const pollFound = polls | ||||
|  |       .find((poll) => poll.id === topicId); | ||||
|  | 
 | ||||
|  |     if (pollHash && pollFound) { | ||||
|  |       if (generatePollHash(pollFound[POLL_QUESTION], pollFound[POLL_OPTIONS]) === pollHash) { | ||||
|  |         setPollHashValid(true); | ||||
|  |         setPollQuestion(pollFound[POLL_QUESTION]); | ||||
|  |         setPollOptions([...pollFound[POLL_OPTIONS]]); | ||||
|  |       } else { | ||||
|  |         setPollHashValid(false); | ||||
|  |       } | ||||
|  | 
 | ||||
|  |       setOrbitDataLoading(false); | ||||
|  |     } | ||||
|  |   }, [pollHash, polls, topicId]); | ||||
|  | 
 | ||||
|  |   const userHasVoted = useMemo(() => hasSignedUp && voters | ||||
|  |     .some((optionVoters) => optionVoters.includes(userAddress)), | ||||
|  |   [hasSignedUp, userAddress, voters]); | ||||
|  | 
 | ||||
|  |   const userVoteIndex = useMemo(() => { | ||||
|  |     if (!chainDataLoading && !orbitDataLoading && userHasVoted) { | ||||
|  |       return voters | ||||
|  |         .findIndex((optionVoters) => optionVoters.includes(userAddress)); | ||||
|  |     } | ||||
|  | 
 | ||||
|  |     return -1; | ||||
|  |   }, [chainDataLoading, orbitDataLoading, userAddress, userHasVoted, voters]); | ||||
|  | 
 | ||||
|  |   const pollVoteTab = useMemo(() => { | ||||
|  |     if (!hasSignedUp) { | ||||
|  |       return <PollGuestView />; | ||||
|  |     } | ||||
|  | 
 | ||||
|  |     if (chainDataLoading || orbitDataLoading) { | ||||
|  |       return null; | ||||
|  |     } | ||||
|  | 
 | ||||
|  |     return ( | ||||
|  |         <PollVote | ||||
|  |           topicId={topicId} | ||||
|  |           pollOptions={pollOptions} | ||||
|  |           enableVoteChanges={pollChangeVoteEnabled} | ||||
|  |           hasUserVoted={userHasVoted} | ||||
|  |           userVoteIndex={userVoteIndex} | ||||
|  |         /> | ||||
|  |     ); | ||||
|  |   }, [ | ||||
|  |     chainDataLoading, hasSignedUp, orbitDataLoading, pollChangeVoteEnabled, pollOptions, topicId, userHasVoted, | ||||
|  |     userVoteIndex, | ||||
|  |   ]); | ||||
|  | 
 | ||||
|  |   const pollGraphTab = useMemo(() => ( | ||||
|  |     !chainDataLoading || orbitDataLoading | ||||
|  |       ? ( | ||||
|  |           <PollGraph | ||||
|  |             pollOptions={pollOptions} | ||||
|  |             voteCounts={voteCounts} | ||||
|  |             hasUserVoted={userHasVoted} | ||||
|  |             userVoteIndex={userVoteIndex} | ||||
|  |           /> | ||||
|  |       ) | ||||
|  |       : null | ||||
|  |   ), [chainDataLoading, orbitDataLoading, pollOptions, userHasVoted, userVoteIndex, voteCounts]); | ||||
|  | 
 | ||||
|  |   const panes = useMemo(() => { | ||||
|  |     const pollVotePane = ( | ||||
|  |         <CustomLoadingTabPane loading={chainDataLoading || orbitDataLoading}> | ||||
|  |             {pollVoteTab} | ||||
|  |         </CustomLoadingTabPane> | ||||
|  |     ); | ||||
|  |     const pollGraphPane = ( | ||||
|  |         <CustomLoadingTabPane loading={chainDataLoading || orbitDataLoading}> | ||||
|  |             {pollGraphTab} | ||||
|  |         </CustomLoadingTabPane> | ||||
|  |     ); | ||||
|  | 
 | ||||
|  |     return ([ | ||||
|  |       { menuItem: t(VOTE_TAB.intl_display_name_id), render: () => pollVotePane }, | ||||
|  |       { menuItem: t(GRAPH_TAB.intl_display_name_id), render: () => pollGraphPane }, | ||||
|  |     ]); | ||||
|  |   }, [chainDataLoading, orbitDataLoading, pollGraphTab, pollVoteTab, t]); | ||||
|  | 
 | ||||
|  |   return ( | ||||
|  |       <Container id="topic-poll-container" textAlign="left"> | ||||
|  |           {!chainDataLoading && !orbitDataLoading && pollHashValid | ||||
|  |             ? ( | ||||
|  |                 <> | ||||
|  |                     <Header as="h3"> | ||||
|  |                         <Icon name="chart pie" size="large" /> | ||||
|  |                         {pollQuestion} | ||||
|  |                     </Header> | ||||
|  |                     <Tab panes={panes} /> | ||||
|  |                 </> | ||||
|  |             ) | ||||
|  |             : <PollDataInvalid />} | ||||
|  |       </Container> | ||||
|  |   ); | ||||
|  | }; | ||||
|  | 
 | ||||
|  | PollView.propTypes = { | ||||
|  |   topicId: PropTypes.number.isRequired, | ||||
|  | }; | ||||
|  | 
 | ||||
|  | export default PollView; | ||||
| @ -0,0 +1,2 @@ | |||||
|  | export const DEFAULT_OPTION_COLOR = '#3B5066'; | ||||
|  | export const CASTED_OPTION_COLOR = '#0b2540'; | ||||
| @ -0,0 +1,16 @@ | |||||
|  | export const VOTE_TAB = { | ||||
|  |   id: 'vote-tab', | ||||
|  |   intl_display_name_id: 'topic.poll.tab.vote.title', | ||||
|  | }; | ||||
|  | 
 | ||||
|  | export const GRAPH_TAB = { | ||||
|  |   id: 'graph-tab', | ||||
|  |   intl_display_name_id: 'topic.poll.tab.graph.title', | ||||
|  | }; | ||||
|  | 
 | ||||
|  | const pollTabs = [ | ||||
|  |   VOTE_TAB, | ||||
|  |   GRAPH_TAB, | ||||
|  | ]; | ||||
|  | 
 | ||||
|  | export default pollTabs; | ||||
| @ -1,7 +1,6 @@ | |||||
| import sha256 from 'crypto-js/sha256'; | import sha256 from 'crypto-js/sha256'; | ||||
| 
 | 
 | ||||
| function generateHash(message) { | export const generateHash = (message) => sha256(message).toString().substring(0, 16); | ||||
|   return sha256(message).toString().substring(0, 16); |  | ||||
| } |  | ||||
| 
 | 
 | ||||
| export default generateHash; | export const generatePollHash = (pollQuestion, pollOptions) => generateHash(JSON | ||||
|  |   .stringify({ question: pollQuestion, optionValues: pollOptions })); | ||||
|  | |||||
					Loading…
					
					
				
		Reference in new issue