aboutsummaryrefslogtreecommitdiff
path: root/src/components/poll
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/poll')
-rw-r--r--src/components/poll/poll.js112
-rw-r--r--src/components/poll/poll.vue134
-rw-r--r--src/components/poll/poll_form.js121
-rw-r--r--src/components/poll/poll_form.vue163
4 files changed, 530 insertions, 0 deletions
diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js
new file mode 100644
index 00000000..98db5582
--- /dev/null
+++ b/src/components/poll/poll.js
@@ -0,0 +1,112 @@
+import Timeago from '../timeago/timeago.vue'
+import { forEach, map } from 'lodash'
+
+export default {
+ name: 'Poll',
+ props: ['basePoll'],
+ components: { Timeago },
+ data () {
+ return {
+ loading: false,
+ choices: []
+ }
+ },
+ created () {
+ if (!this.$store.state.polls.pollsObject[this.pollId]) {
+ this.$store.dispatch('mergeOrAddPoll', this.basePoll)
+ }
+ this.$store.dispatch('trackPoll', this.pollId)
+ },
+ destroyed () {
+ this.$store.dispatch('untrackPoll', this.pollId)
+ },
+ computed: {
+ pollId () {
+ return this.basePoll.id
+ },
+ poll () {
+ const storePoll = this.$store.state.polls.pollsObject[this.pollId]
+ return storePoll || {}
+ },
+ options () {
+ return (this.poll && this.poll.options) || []
+ },
+ expiresAt () {
+ return (this.poll && this.poll.expires_at) || 0
+ },
+ expired () {
+ return (this.poll && this.poll.expired) || false
+ },
+ loggedIn () {
+ return this.$store.state.users.currentUser
+ },
+ showResults () {
+ return this.poll.voted || this.expired || !this.loggedIn
+ },
+ totalVotesCount () {
+ return this.poll.votes_count
+ },
+ containerClass () {
+ return {
+ loading: this.loading
+ }
+ },
+ choiceIndices () {
+ // Convert array of booleans into an array of indices of the
+ // items that were 'true', so [true, false, false, true] becomes
+ // [0, 3].
+ return this.choices
+ .map((entry, index) => entry && index)
+ .filter(value => typeof value === 'number')
+ },
+ isDisabled () {
+ const noChoice = this.choiceIndices.length === 0
+ return this.loading || noChoice
+ }
+ },
+ methods: {
+ percentageForOption (count) {
+ return this.totalVotesCount === 0 ? 0 : Math.round(count / this.totalVotesCount * 100)
+ },
+ resultTitle (option) {
+ return `${option.votes_count}/${this.totalVotesCount} ${this.$t('polls.votes')}`
+ },
+ fetchPoll () {
+ this.$store.dispatch('refreshPoll', { id: this.statusId, pollId: this.poll.id })
+ },
+ activateOption (index) {
+ // forgive me father: doing checking the radio/checkboxes
+ // in code because of customized input elements need either
+ // a) an extra element for the actual graphic, or b) use a
+ // pseudo element for the label. We use b) which mandates
+ // using "for" and "id" matching which isn't nice when the
+ // same poll appears multiple times on the site (notifs and
+ // timeline for example). With code we can make sure it just
+ // works without altering the pseudo element implementation.
+ const allElements = this.$el.querySelectorAll('input')
+ const clickedElement = this.$el.querySelector(`input[value="${index}"]`)
+ if (this.poll.multiple) {
+ // Checkboxes, toggle only the clicked one
+ clickedElement.checked = !clickedElement.checked
+ } else {
+ // Radio button, uncheck everything and check the clicked one
+ forEach(allElements, element => { element.checked = false })
+ clickedElement.checked = true
+ }
+ this.choices = map(allElements, e => e.checked)
+ },
+ optionId (index) {
+ return `poll${this.poll.id}-${index}`
+ },
+ vote () {
+ if (this.choiceIndices.length === 0) return
+ this.loading = true
+ this.$store.dispatch(
+ 'votePoll',
+ { id: this.statusId, pollId: this.poll.id, choices: this.choiceIndices }
+ ).then(poll => {
+ this.loading = false
+ })
+ }
+ }
+}
diff --git a/src/components/poll/poll.vue b/src/components/poll/poll.vue
new file mode 100644
index 00000000..db8e33b3
--- /dev/null
+++ b/src/components/poll/poll.vue
@@ -0,0 +1,134 @@
+<template>
+ <div
+ class="poll"
+ :class="containerClass"
+ >
+ <div
+ v-for="(option, index) in options"
+ :key="index"
+ class="poll-option"
+ >
+ <div
+ v-if="showResults"
+ :title="resultTitle(option)"
+ class="option-result"
+ >
+ <div class="option-result-label">
+ <span class="result-percentage">
+ {{ percentageForOption(option.votes_count) }}%
+ </span>
+ <span>{{ option.title }}</span>
+ </div>
+ <div
+ class="result-fill"
+ :style="{ 'width': `${percentageForOption(option.votes_count)}%` }"
+ />
+ </div>
+ <div
+ v-else
+ @click="activateOption(index)"
+ >
+ <input
+ v-if="poll.multiple"
+ type="checkbox"
+ :disabled="loading"
+ :value="index"
+ >
+ <input
+ v-else
+ type="radio"
+ :disabled="loading"
+ :value="index"
+ >
+ <label class="option-vote">
+ <div>{{ option.title }}</div>
+ </label>
+ </div>
+ </div>
+ <div class="footer faint">
+ <button
+ v-if="!showResults"
+ class="btn btn-default poll-vote-button"
+ type="button"
+ :disabled="isDisabled"
+ @click="vote"
+ >
+ {{ $t('polls.vote') }}
+ </button>
+ <div class="total">
+ {{ totalVotesCount }} {{ $t("polls.votes") }}&nbsp;ยท&nbsp;
+ </div>
+ <i18n :path="expired ? 'polls.expired' : 'polls.expires_in'">
+ <Timeago
+ :time="expiresAt"
+ :auto-update="60"
+ :now-threshold="0"
+ />
+ </i18n>
+ </div>
+ </div>
+</template>
+
+<script src="./poll.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.poll {
+ .votes {
+ display: flex;
+ flex-direction: column;
+ margin: 0 0 0.5em;
+ }
+ .poll-option {
+ margin: 0.75em 0.5em;
+ }
+ .option-result {
+ height: 100%;
+ display: flex;
+ flex-direction: row;
+ position: relative;
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+ }
+ .option-result-label {
+ display: flex;
+ align-items: center;
+ padding: 0.1em 0.25em;
+ z-index: 1;
+ }
+ .result-percentage {
+ width: 3.5em;
+ flex-shrink: 0;
+ }
+ .result-fill {
+ height: 100%;
+ position: absolute;
+ background-color: $fallback--lightBg;
+ background-color: var(--linkBg, $fallback--lightBg);
+ border-radius: $fallback--panelRadius;
+ border-radius: var(--panelRadius, $fallback--panelRadius);
+ top: 0;
+ left: 0;
+ transition: width 0.5s;
+ }
+ .option-vote {
+ display: flex;
+ align-items: center;
+ }
+ input {
+ width: 3.5em;
+ }
+ .footer {
+ display: flex;
+ align-items: center;
+ }
+ &.loading * {
+ cursor: progress;
+ }
+ .poll-vote-button {
+ padding: 0 0.5em;
+ margin-right: 0.5em;
+ }
+}
+</style>
diff --git a/src/components/poll/poll_form.js b/src/components/poll/poll_form.js
new file mode 100644
index 00000000..c0c1ccf7
--- /dev/null
+++ b/src/components/poll/poll_form.js
@@ -0,0 +1,121 @@
+import * as DateUtils from 'src/services/date_utils/date_utils.js'
+import { uniq } from 'lodash'
+
+export default {
+ name: 'PollForm',
+ props: ['visible'],
+ data: () => ({
+ pollType: 'single',
+ options: ['', ''],
+ expiryAmount: 10,
+ expiryUnit: 'minutes'
+ }),
+ computed: {
+ pollLimits () {
+ return this.$store.state.instance.pollLimits
+ },
+ maxOptions () {
+ return this.pollLimits.max_options
+ },
+ maxLength () {
+ return this.pollLimits.max_option_chars
+ },
+ expiryUnits () {
+ const allUnits = ['minutes', 'hours', 'days']
+ const expiry = this.convertExpiryFromUnit
+ return allUnits.filter(
+ unit => this.pollLimits.max_expiration >= expiry(unit, 1)
+ )
+ },
+ minExpirationInCurrentUnit () {
+ return Math.ceil(
+ this.convertExpiryToUnit(
+ this.expiryUnit,
+ this.pollLimits.min_expiration
+ )
+ )
+ },
+ maxExpirationInCurrentUnit () {
+ return Math.floor(
+ this.convertExpiryToUnit(
+ this.expiryUnit,
+ this.pollLimits.max_expiration
+ )
+ )
+ }
+ },
+ methods: {
+ clear () {
+ this.pollType = 'single'
+ this.options = ['', '']
+ this.expiryAmount = 10
+ this.expiryUnit = 'minutes'
+ },
+ nextOption (index) {
+ const element = this.$el.querySelector(`#poll-${index + 1}`)
+ if (element) {
+ element.focus()
+ } else {
+ // Try adding an option and try focusing on it
+ const addedOption = this.addOption()
+ if (addedOption) {
+ this.$nextTick(function () {
+ this.nextOption(index)
+ })
+ }
+ }
+ },
+ addOption () {
+ if (this.options.length < this.maxOptions) {
+ this.options.push('')
+ return true
+ }
+ return false
+ },
+ deleteOption (index, event) {
+ if (this.options.length > 2) {
+ this.options.splice(index, 1)
+ }
+ },
+ convertExpiryToUnit (unit, amount) {
+ // Note: we want seconds and not milliseconds
+ switch (unit) {
+ case 'minutes': return (1000 * amount) / DateUtils.MINUTE
+ case 'hours': return (1000 * amount) / DateUtils.HOUR
+ case 'days': return (1000 * amount) / DateUtils.DAY
+ }
+ },
+ convertExpiryFromUnit (unit, amount) {
+ // Note: we want seconds and not milliseconds
+ switch (unit) {
+ case 'minutes': return 0.001 * amount * DateUtils.MINUTE
+ case 'hours': return 0.001 * amount * DateUtils.HOUR
+ case 'days': return 0.001 * amount * DateUtils.DAY
+ }
+ },
+ expiryAmountChange () {
+ this.expiryAmount =
+ Math.max(this.minExpirationInCurrentUnit, this.expiryAmount)
+ this.expiryAmount =
+ Math.min(this.maxExpirationInCurrentUnit, this.expiryAmount)
+ this.updatePollToParent()
+ },
+ updatePollToParent () {
+ const expiresIn = this.convertExpiryFromUnit(
+ this.expiryUnit,
+ this.expiryAmount
+ )
+
+ const options = uniq(this.options.filter(option => option !== ''))
+ if (options.length < 2) {
+ this.$emit('update-poll', { error: this.$t('polls.not_enough_options') })
+ return
+ }
+ this.$emit('update-poll', {
+ options,
+ multiple: this.pollType === 'multiple',
+ expiresIn
+ })
+ }
+ }
+}
diff --git a/src/components/poll/poll_form.vue b/src/components/poll/poll_form.vue
new file mode 100644
index 00000000..d53f3837
--- /dev/null
+++ b/src/components/poll/poll_form.vue
@@ -0,0 +1,163 @@
+<template>
+ <div
+ v-if="visible"
+ class="poll-form"
+ >
+ <div
+ v-for="(option, index) in options"
+ :key="index"
+ class="poll-option"
+ >
+ <div class="input-container">
+ <input
+ :id="`poll-${index}`"
+ v-model="options[index]"
+ class="poll-option-input"
+ type="text"
+ :placeholder="$t('polls.option')"
+ :maxlength="maxLength"
+ @change="updatePollToParent"
+ @keydown.enter.stop.prevent="nextOption(index)"
+ >
+ </div>
+ <div
+ v-if="options.length > 2"
+ class="icon-container"
+ >
+ <i
+ class="icon-cancel"
+ @click="deleteOption(index)"
+ />
+ </div>
+ </div>
+ <a
+ v-if="options.length < maxOptions"
+ class="add-option faint"
+ @click="addOption"
+ >
+ <i class="icon-plus" />
+ {{ $t("polls.add_option") }}
+ </a>
+ <div class="poll-type-expiry">
+ <div
+ class="poll-type"
+ :title="$t('polls.type')"
+ >
+ <label
+ for="poll-type-selector"
+ class="select"
+ >
+ <select
+ v-model="pollType"
+ class="select"
+ @change="updatePollToParent"
+ >
+ <option value="single">{{ $t('polls.single_choice') }}</option>
+ <option value="multiple">{{ $t('polls.multiple_choices') }}</option>
+ </select>
+ <i class="icon-down-open" />
+ </label>
+ </div>
+ <div
+ class="poll-expiry"
+ :title="$t('polls.expiry')"
+ >
+ <input
+ v-model="expiryAmount"
+ type="number"
+ class="expiry-amount hide-number-spinner"
+ :min="minExpirationInCurrentUnit"
+ :max="maxExpirationInCurrentUnit"
+ @change="expiryAmountChange"
+ >
+ <label class="expiry-unit select">
+ <select
+ v-model="expiryUnit"
+ @change="expiryAmountChange"
+ >
+ <option
+ v-for="unit in expiryUnits"
+ :key="unit"
+ :value="unit"
+ >
+ {{ $t(`time.${unit}_short`, ['']) }}
+ </option>
+ </select>
+ <i class="icon-down-open" />
+ </label>
+ </div>
+ </div>
+ </div>
+</template>
+
+<script src="./poll_form.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.poll-form {
+ display: flex;
+ flex-direction: column;
+ padding: 0 0.5em 0.5em;
+
+ .add-option {
+ align-self: flex-start;
+ padding-top: 0.25em;
+ cursor: pointer;
+ }
+
+ .poll-option {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ margin-bottom: 0.25em;
+ }
+
+ .input-container {
+ width: 100%;
+ input {
+ // Hack: dodge the floating X icon
+ padding-right: 2.5em;
+ width: 100%;
+ }
+ }
+
+ .icon-container {
+ // Hack: Move the icon over the input box
+ width: 2em;
+ margin-left: -2em;
+ z-index: 1;
+ }
+
+ .poll-type-expiry {
+ margin-top: 0.5em;
+ display: flex;
+ width: 100%;
+ }
+
+ .poll-type {
+ margin-right: 0.75em;
+ flex: 1 1 60%;
+ .select {
+ border: none;
+ box-shadow: none;
+ background-color: transparent;
+ }
+ }
+
+ .poll-expiry {
+ display: flex;
+
+ .expiry-amount {
+ width: 3em;
+ text-align: right;
+ }
+
+ .expiry-unit {
+ border: none;
+ box-shadow: none;
+ background-color: transparent;
+ }
+ }
+}
+</style>