diff --git a/packages/concordia-app/package.json b/packages/concordia-app/package.json index 54356c7..f911dc0 100644 --- a/packages/concordia-app/package.json +++ b/packages/concordia-app/package.json @@ -29,6 +29,7 @@ "@ezerous/eth-identity-provider": "~0.1.2", "@reduxjs/toolkit": "~1.4.0", "@welldone-software/why-did-you-render": "~6.0.5", + "apexcharts": "^3.26.0", "concordia-contracts": "~0.1.0", "concordia-shared": "~0.1.0", "i18next": "^19.8.3", @@ -37,6 +38,7 @@ "lodash": "^4.17.20", "prop-types": "~15.7.2", "react": "~16.13.1", + "react-apexcharts": "^1.3.7", "react-avatar": "~3.9.7", "react-copy-to-clipboard": "^5.0.3", "react-dom": "~16.13.1", diff --git a/packages/concordia-app/public/locales/en/translation.json b/packages/concordia-app/public/locales/en/translation.json index d4661ce..f9799bd 100644 --- a/packages/concordia-app/public/locales/en/translation.json +++ b/packages/concordia-app/public/locales/en/translation.json @@ -74,6 +74,11 @@ "topic.create.form.subject.field.label": "Topic subject", "topic.create.form.subject.field.placeholder": "Subject", "topic.list.row.topic.id": "#{{id}}", + "topic.poll.tab.graph.title": "Results", + "topic.poll.tab.results.votes.count": "{{totalVotes}} votes casted", + "topic.poll.tab.vote.form.button.submit": "Submit", + "topic.poll.tab.vote.form.radio.label": "Select one of the options:", + "topic.poll.tab.vote.title": "Vote", "username.selector.error.username.empty.message": "Username is required", "username.selector.error.username.taken.message": "The username {{username}} is already taken.", "username.selector.username.field.label": "Username", diff --git a/packages/concordia-app/src/components/PollView/PollGraph/index.jsx b/packages/concordia-app/src/components/PollView/PollGraph/index.jsx new file mode 100644 index 0000000..be698a9 --- /dev/null +++ b/packages/concordia-app/src/components/PollView/PollGraph/index.jsx @@ -0,0 +1,82 @@ +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, userVoteHash, + } = props; + const { t } = useTranslation(); + + const chartOptions = useMemo(() => ({ + chart: { + id: 'topic-poll', + }, + plotOptions: { + bar: { + horizontal: true, + }, + }, + colors: [ + (value) => { + if (hasUserVoted && pollOptions[value.dataPointIndex].hash === userVoteHash) { + return CASTED_OPTION_COLOR; + } + return DEFAULT_OPTION_COLOR; + }, + ], + xaxis: { + categories: pollOptions.map((pollOption) => pollOption.label), + }, + }), [hasUserVoted, pollOptions, userVoteHash]); + + const chartSeries = useMemo(() => [{ + name: 'votes', + data: voteCounts, + }], [voteCounts]); + + return ( + + + + + + + + + + +
+ {t('topic.poll.tab.results.votes.count', { + totalVotes: voteCounts.reduce((accumulator, voteCount) => accumulator + voteCount, 0), + })} +
+
+
+
+ ); +}; + +PollGraph.defaultProps = { + hasUserVoted: false, + userVoteHash: '', +}; + +PollGraph.propTypes = { + pollOptions: PropTypes.arrayOf(PropTypes.exact({ + label: PropTypes.string, + hash: PropTypes.string, + })).isRequired, + voteCounts: PropTypes.arrayOf(PropTypes.number).isRequired, + hasUserVoted: PropTypes.bool, + userVoteHash: PropTypes.string, +}; + +export default PollGraph; diff --git a/packages/concordia-app/src/components/PollView/PollVote/index.jsx b/packages/concordia-app/src/components/PollView/PollVote/index.jsx new file mode 100644 index 0000000..3b4641e --- /dev/null +++ b/packages/concordia-app/src/components/PollView/PollVote/index.jsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { Form } from 'semantic-ui-react'; +import { useTranslation } from 'react-i18next'; + +const PollVote = (props) => { + const { + pollOptions, enableVoteChanges, hasUserVoted, userVoteHash, + } = props; + const [selectedOptionHash, setSelectedOptionHash] = useState(userVoteHash); + const { t } = useTranslation(); + + const onOptionSelected = (e, { value }) => { + setSelectedOptionHash(value); + }; + + const onCastVote = () => { + console.log('vote'); + // TODO + // TODO: callback for immediate poll data refresh on vote? + }; + + return ( +
+ + + {pollOptions.map((pollOption) => ( + + ))} + + + {t('topic.poll.tab.vote.form.button.submit')} + +
+ ); +}; + +PollVote.defaultProps = { + userVoteHash: '', +}; + +PollVote.propTypes = { + pollOptions: PropTypes.arrayOf(PropTypes.exact({ + label: PropTypes.string, + hash: PropTypes.string, + })).isRequired, + enableVoteChanges: PropTypes.bool.isRequired, + hasUserVoted: PropTypes.bool.isRequired, + userVoteHash: PropTypes.string, +}; + +export default PollVote; diff --git a/packages/concordia-app/src/components/PollView/index.jsx b/packages/concordia-app/src/components/PollView/index.jsx new file mode 100644 index 0000000..6c65d05 --- /dev/null +++ b/packages/concordia-app/src/components/PollView/index.jsx @@ -0,0 +1,99 @@ +import React, { useMemo, useState } from 'react'; +import { 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 { 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'; + +const { contracts: { [VOTING_CONTRACT]: { methods: { pollExists: { cacheCall: pollExistsChainData } } } } } = drizzle; + +const hashOption = (val) => { + let hash = 0; + let i; + let chr; + + for (i = 0; i < val.length; i++) { + chr = val.charCodeAt(i); + hash = ((hash << 5) - hash) + chr; + hash |= 0; + } + + return `${hash}`; +}; + +const PollView = (props) => { + const { topicId } = props; + const userAddress = useSelector((state) => state.user.address); + const hasSignedUp = useSelector((state) => state.user.hasSignedUp); + const getPollInfoResults = useSelector((state) => state.contracts[VOTING_CONTRACT].getPollInfo); + const [getPollInfoCallHash, setGetPollInfoCallHash] = useState(null); + const [pollOptions, setPollOptions] = useState([ + { + label: 'option 1', + hash: hashOption('option 1'), + }, + { + label: 'option 2', + hash: hashOption('option 2'), + }, + { + label: 'option 3', + hash: hashOption('option 3'), + }, + { + label: 'option 4', + hash: hashOption('option 4'), + }, + { + label: 'option 5', + hash: hashOption('option 5'), + }, + + ]); + const [voteCounts, setVoteCounts] = useState([2, 8, 4, 12, 7]); + const [voteLoading, setVoteLoading] = useState(false); + const [resultsLoading, setResultsLoading] = useState(false); + const { t } = useTranslation(); + + // TODO: get vote options + + // TODO: get current results + + // TODO: check poll hash validity, add invalid view + + const pollVoteTab = useMemo(() => ( + + ), [pollOptions]); + + const pollGraphTab = useMemo(() => ( + + ), [pollOptions, voteCounts]); + + const panes = useMemo(() => { + const pollVotePane = ({pollVoteTab}); + const pollGraphPane = ({pollGraphTab}); + + return ([ + { menuItem: t(VOTE_TAB.intl_display_name_id), render: () => pollVotePane }, + { menuItem: t(GRAPH_TAB.intl_display_name_id), render: () => pollGraphPane }, + ]); + }, [pollGraphTab, pollVoteTab, resultsLoading, t, voteLoading]); + + return ( + +
+ + Do you thing asdf or fdsa? +
+ +
+ ); +}; + +export default PollView; diff --git a/packages/concordia-app/src/constants/polls/PollGraph.js b/packages/concordia-app/src/constants/polls/PollGraph.js new file mode 100644 index 0000000..a19a2ee --- /dev/null +++ b/packages/concordia-app/src/constants/polls/PollGraph.js @@ -0,0 +1,2 @@ +export const DEFAULT_OPTION_COLOR = '#3B5066'; +export const CASTED_OPTION_COLOR = '#0b2540'; diff --git a/packages/concordia-app/src/constants/polls/PollTabs.js b/packages/concordia-app/src/constants/polls/PollTabs.js new file mode 100644 index 0000000..b48a05e --- /dev/null +++ b/packages/concordia-app/src/constants/polls/PollTabs.js @@ -0,0 +1,16 @@ +export const VOTE_TAB = { + id: 'vote-tab', + intl_display_name_id: 'topic.poll.vote.tab.title', +}; + +export const GRAPH_TAB = { + id: 'graph-tab', + intl_display_name_id: 'topic.poll.graph.tab.title', +}; + +const pollTabs = [ + VOTE_TAB, + GRAPH_TAB, +]; + +export default pollTabs; diff --git a/packages/concordia-app/src/views/Topic/TopicView/index.jsx b/packages/concordia-app/src/views/Topic/TopicView/index.jsx index 602fa54..8e1f6d3 100644 --- a/packages/concordia-app/src/views/Topic/TopicView/index.jsx +++ b/packages/concordia-app/src/views/Topic/TopicView/index.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { @@ -6,19 +6,25 @@ import { } from 'semantic-ui-react'; import { Link } from 'react-router-dom'; import { useHistory } from 'react-router'; -import { FORUM_CONTRACT } from 'concordia-shared/src/constants/contracts/ContractNames'; +import { FORUM_CONTRACT, VOTING_CONTRACT } from 'concordia-shared/src/constants/contracts/ContractNames'; import { TOPICS_DATABASE, USER_DATABASE } from 'concordia-shared/src/constants/orbit/OrbitDatabases'; import ReactMarkdown from 'react-markdown'; import { breeze, drizzle } from '../../../redux/store'; import { FETCH_USER_DATABASE } from '../../../redux/actions/peerDbReplicationActions'; import './styles.css'; import TopicPostList from './TopicPostList'; +import PollView from '../../../components/PollView'; import determineKVAddress from '../../../utils/orbitUtils'; import { TOPIC_SUBJECT } from '../../../constants/orbit/TopicsDatabaseKeys'; import PostCreate from '../../../components/PostCreate'; import targetBlank from '../../../utils/markdownUtils'; -const { contracts: { [FORUM_CONTRACT]: { methods: { getTopic: { cacheCall: getTopicChainData } } } } } = drizzle; +const { + contracts: { + [FORUM_CONTRACT]: { methods: { getTopic: { cacheCall: getTopicChainData } } }, + [VOTING_CONTRACT]: { methods: { pollExists: { cacheCall: pollExistsChainData } } }, + }, +} = drizzle; const { orbit } = breeze; const TopicView = (props) => { @@ -29,15 +35,18 @@ const TopicView = (props) => { const userAddress = useSelector((state) => state.user.address); const hasSignedUp = useSelector((state) => state.user.hasSignedUp); const getTopicResults = useSelector((state) => state.contracts[FORUM_CONTRACT].getTopic); + const pollExistsResults = useSelector((state) => state.contracts[VOTING_CONTRACT].pollExists); const topics = useSelector((state) => state.orbitData.topics); const users = useSelector((state) => state.orbitData.users); - const [getTopicCallHash, setGetTopicCallHash] = useState([]); + const [getTopicCallHash, setGetTopicCallHash] = useState(null); + const [pollExistsCallHash, setPollExistsCallHash] = useState(null); const [topicAuthorAddress, setTopicAuthorAddress] = useState(initialTopicAuthorAddress || null); const [topicAuthor, setTopicAuthor] = useState(initialTopicAuthor || null); const [timestamp, setTimestamp] = useState(initialTimestamp || null); const [postIds, setPostIds] = useState(initialPostIds || null); const [numberOfReplies, setReplyCount] = useState(0); const [topicSubject, setTopicSubject] = useState(null); + const [hasPoll, setHasPoll] = useState(false); const history = useHistory(); const dispatch = useDispatch(); @@ -52,6 +61,10 @@ const TopicView = (props) => { } }, [postIds, timestamp, topicAuthor, topicAuthorAddress, topicId]); + useEffect(() => { + setPollExistsCallHash(pollExistsChainData(topicId)); + }, [topicId]); + useEffect(() => { if (getTopicCallHash && getTopicResults && getTopicResults[getTopicCallHash]) { if (getTopicResults[getTopicCallHash].value == null) { @@ -62,9 +75,9 @@ const TopicView = (props) => { setTopicAuthorAddress(getTopicResults[getTopicCallHash].value[0]); setTopicAuthor(getTopicResults[getTopicCallHash].value[1]); setTimestamp(getTopicResults[getTopicCallHash].value[2] * 1000); - const postIds = getTopicResults[getTopicCallHash].value[3].map((postId) => parseInt(postId, 10)); - setPostIds(postIds); - setReplyCount(postIds.length - 1); + const fetchedPostIds = getTopicResults[getTopicCallHash].value[3].map((postId) => parseInt(postId, 10)); + setPostIds(fetchedPostIds); + setReplyCount(fetchedPostIds.length - 1); const topicFound = topics .find((topic) => topic.id === topicId); @@ -80,6 +93,12 @@ const TopicView = (props) => { } }, [dispatch, getTopicCallHash, getTopicResults, history, topicId, topics, userAddress]); + useEffect(() => { + if (pollExistsCallHash && pollExistsResults && pollExistsResults[pollExistsCallHash]) { + setHasPoll(pollExistsResults[pollExistsCallHash].value); + } + }, [pollExistsCallHash, pollExistsResults, topicId]); + useEffect(() => { if (topicAuthorAddress !== null) { determineKVAddress({ orbit, dbName: USER_DATABASE, userAddress: topicAuthorAddress }) @@ -111,6 +130,8 @@ const TopicView = (props) => { } }, [topicId, topics]); + const poll = useMemo(() => hasPoll && , [hasPoll]); + const stopClickPropagation = (event) => { event.stopPropagation(); }; @@ -147,7 +168,9 @@ const TopicView = (props) => {       - { topicAuthor } + + {topicAuthor} +       @@ -155,6 +178,16 @@ const TopicView = (props) => { + { + hasPoll && ( + <> +
+ {poll} +
+ + + ) + } diff --git a/yarn.lock b/yarn.lock index 4dc7c8e..03d6ff4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3084,6 +3084,18 @@ anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" +apexcharts@^3.26.0: + version "3.26.0" + resolved "https://registry.yarnpkg.com/apexcharts/-/apexcharts-3.26.0.tgz#a78abc108b2e1b3086a738f0ec7c98e292f4a14b" + integrity sha512-zdYHs3k3tgmCn1BpYLj7rhGEndBYF33Pq1+g0ora37xAr+3act5CJrpdXM2jx2boVUyXgavoSp6sa8WpK7RkSA== + dependencies: + svg.draggable.js "^2.2.2" + svg.easing.js "^2.0.0" + svg.filter.js "^2.0.2" + svg.pathmorphing.js "^0.1.3" + svg.resize.js "^1.4.3" + svg.select.js "^3.0.1" + app-module-path@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/app-module-path/-/app-module-path-2.2.0.tgz#641aa55dfb7d6a6f0a8141c4b9c0aa50b6c24dd5" @@ -14910,7 +14922,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@~15.7.2: +prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@~15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -15185,6 +15197,13 @@ rc@^1.2.7, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-apexcharts@^1.3.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/react-apexcharts/-/react-apexcharts-1.3.7.tgz#42c8785e260535a4f8db1aadbe7b66552770944d" + integrity sha512-2OFhEHd70/WHN0kmrJtVx37UfaL71ZogVkwezmDqwQWgwhK6upuhlnEEX7tEq4xvjA+RFDn6hiUTNIuC/Q7Zqw== + dependencies: + prop-types "^15.5.7" + react-app-polyfill@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/react-app-polyfill/-/react-app-polyfill-1.0.6.tgz#890f8d7f2842ce6073f030b117de9130a5f385f0" @@ -16469,6 +16488,9 @@ snake-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c" integrity sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg== + dependencies: + dot-case "^3.0.4" + tslib "^2.0.3" snapdragon-node@^2.0.1: version "2.1.1" @@ -17163,6 +17185,61 @@ svg-parser@^2.0.0: resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== +svg.draggable.js@^2.2.2: + version "2.2.2" + resolved "https://registry.yarnpkg.com/svg.draggable.js/-/svg.draggable.js-2.2.2.tgz#c514a2f1405efb6f0263e7958f5b68fce50603ba" + integrity sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw== + dependencies: + svg.js "^2.0.1" + +svg.easing.js@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/svg.easing.js/-/svg.easing.js-2.0.0.tgz#8aa9946b0a8e27857a5c40a10eba4091e5691f12" + integrity sha1-iqmUawqOJ4V6XEChDrpAkeVpHxI= + dependencies: + svg.js ">=2.3.x" + +svg.filter.js@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/svg.filter.js/-/svg.filter.js-2.0.2.tgz#91008e151389dd9230779fcbe6e2c9a362d1c203" + integrity sha1-kQCOFROJ3ZIwd5/L5uLJo2LRwgM= + dependencies: + svg.js "^2.2.5" + +svg.js@>=2.3.x, svg.js@^2.0.1, svg.js@^2.2.5, svg.js@^2.4.0, svg.js@^2.6.5: + version "2.7.1" + resolved "https://registry.yarnpkg.com/svg.js/-/svg.js-2.7.1.tgz#eb977ed4737001eab859949b4a398ee1bb79948d" + integrity sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA== + +svg.pathmorphing.js@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/svg.pathmorphing.js/-/svg.pathmorphing.js-0.1.3.tgz#c25718a1cc7c36e852ecabc380e758ac09bb2b65" + integrity sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww== + dependencies: + svg.js "^2.4.0" + +svg.resize.js@^1.4.3: + version "1.4.3" + resolved "https://registry.yarnpkg.com/svg.resize.js/-/svg.resize.js-1.4.3.tgz#885abd248e0cd205b36b973c4b578b9a36f23332" + integrity sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw== + dependencies: + svg.js "^2.6.5" + svg.select.js "^2.1.2" + +svg.select.js@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-2.1.2.tgz#e41ce13b1acff43a7441f9f8be87a2319c87be73" + integrity sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ== + dependencies: + svg.js "^2.2.5" + +svg.select.js@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/svg.select.js/-/svg.select.js-3.0.1.tgz#a4198e359f3825739226415f82176a90ea5cc917" + integrity sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw== + dependencies: + svg.js "^2.6.5" + svgo@^1.0.0, svgo@^1.2.2: version "1.3.2" resolved "https://registry.yarnpkg.com/svgo/-/svgo-1.3.2.tgz#b6dc511c063346c9e415b81e43401145b96d4167"