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'; |
|||
|
|||
function generateHash(message) { |
|||
return sha256(message).toString().substring(0, 16); |
|||
} |
|||
export const generateHash = (message) => 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