mirror of https://gitlab.com/ecentrics/concordia
Apostolos Fanakis
4 years ago
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