Feature/polls attempt 2
This commit is contained in:
parent
69eff65130
commit
0eed2ccca8
56 changed files with 1364 additions and 1458 deletions
107
src/components/poll/poll.js
Normal file
107
src/components/poll/poll.js
Normal file
|
@ -0,0 +1,107 @@
|
|||
import Timeago from '../timeago/timeago.vue'
|
||||
import { forEach, map } from 'lodash'
|
||||
|
||||
export default {
|
||||
name: 'Poll',
|
||||
props: ['poll', 'statusId'],
|
||||
components: { Timeago },
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
choices: [],
|
||||
refreshInterval: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.refreshInterval = setTimeout(this.refreshPoll, 30 * 1000)
|
||||
// Initialize choices to booleans and set its length to match options
|
||||
this.choices = this.poll.options.map(_ => false)
|
||||
},
|
||||
destroyed () {
|
||||
clearTimeout(this.refreshInterval)
|
||||
},
|
||||
computed: {
|
||||
expired () {
|
||||
return Date.now() > Date.parse(this.poll.expires_at)
|
||||
},
|
||||
loggedIn () {
|
||||
return this.$store.state.users.currentUser
|
||||
},
|
||||
showResults () {
|
||||
return this.poll.voted || this.expired || !this.loggedIn
|
||||
},
|
||||
totalVotesCount () {
|
||||
return this.poll.votes_count
|
||||
},
|
||||
expiresAt () {
|
||||
return Date.parse(this.poll.expires_at).toLocaleString()
|
||||
},
|
||||
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: {
|
||||
refreshPoll () {
|
||||
if (this.expired) return
|
||||
this.fetchPoll()
|
||||
this.refreshInterval = setTimeout(this.refreshPoll, 30 * 1000)
|
||||
},
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
117
src/components/poll/poll.vue
Normal file
117
src/components/poll/poll.vue
Normal file
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<div class="poll" v-bind:class="containerClass">
|
||||
<div
|
||||
class="poll-option"
|
||||
v-for="(option, index) in poll.options"
|
||||
:key="index"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
{{option.title}}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer faint">
|
||||
<button
|
||||
v-if="!showResults"
|
||||
class="btn btn-default poll-vote-button"
|
||||
type="button"
|
||||
@click="vote"
|
||||
:disabled="isDisabled"
|
||||
>
|
||||
{{$t('polls.vote')}}
|
||||
</button>
|
||||
<div class="total">
|
||||
{{totalVotesCount}} {{ $t("polls.votes") }} ·
|
||||
</div>
|
||||
<i18n :path="expired ? 'polls.expired' : 'polls.expires_in'">
|
||||
<Timeago :time="this.poll.expires_at" :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.5em 0;
|
||||
height: 1.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
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>
|
121
src/components/poll/poll_form.js
Normal file
121
src/components/poll/poll_form.js
Normal file
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
133
src/components/poll/poll_form.vue
Normal file
133
src/components/poll/poll_form.vue
Normal file
|
@ -0,0 +1,133 @@
|
|||
<template>
|
||||
<div class="poll-form" v-if="visible">
|
||||
<div class="poll-option" v-for="(option, index) in options" :key="index">
|
||||
<div class="input-container">
|
||||
<input
|
||||
class="poll-option-input"
|
||||
type="text"
|
||||
:placeholder="$t('polls.option')"
|
||||
:maxlength="maxLength"
|
||||
:id="`poll-${index}`"
|
||||
v-model="options[index]"
|
||||
@change="updatePollToParent"
|
||||
@keydown.enter.stop.prevent="nextOption(index)"
|
||||
>
|
||||
</div>
|
||||
<div class="icon-container" v-if="options.length > 2">
|
||||
<i class="icon-cancel" @click="deleteOption(index)"></i>
|
||||
</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 class="select" v-model="pollType" @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
|
||||
type="number"
|
||||
class="expiry-amount hide-number-spinner"
|
||||
:min="minExpirationInCurrentUnit"
|
||||
:max="maxExpirationInCurrentUnit"
|
||||
v-model="expiryAmount"
|
||||
@change="expiryAmountChange"
|
||||
>
|
||||
<label class="expiry-unit select">
|
||||
<select
|
||||
v-model="expiryUnit"
|
||||
@change="expiryAmountChange"
|
||||
>
|
||||
<option v-for="unit in expiryUnits" :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>
|
Loading…
Add table
Add a link
Reference in a new issue