diff options
Diffstat (limited to 'src/components/poll')
| -rw-r--r-- | src/components/poll/poll.js | 112 | ||||
| -rw-r--r-- | src/components/poll/poll.vue | 134 | ||||
| -rw-r--r-- | src/components/poll/poll_form.js | 121 | ||||
| -rw-r--r-- | src/components/poll/poll_form.vue | 163 |
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") }} ยท + </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> |
