Merge pull request #460 from nextcloud/enh/result_summary

Add summary response visualization, fix #314
This commit is contained in:
Jan C. Borchardt 2020-07-07 15:29:40 +02:00 committed by GitHub
commit 4b902ba93a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 334 additions and 22 deletions

View file

@ -0,0 +1,242 @@
<!--
- @copyright Copyright (c) 2020 Jan C. Borchardt https://jancborchardt.net
-
- @author Jan C. Borchardt https://jancborchardt.net
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="section question-summary">
<h3>{{ question.text }}</h3>
<p class="question-summary__detail">
{{ answerTypes[question.type].label }}
</p>
<!-- Answers with countable results for visualization -->
<ol v-if="question.type === 'multiple' || question.type === 'multiple_unique' || question.type === 'dropdown'"
class="question-summary__statistic">
<li v-for="option in questionOptions"
:key="option.id">
<label :for="`option-${option.questionId}-${option.id}`">
{{ option.count }}
<span class="question-summary__statistic-percentage">
({{ option.percentage }}%):
</span>
<span class="question-summary__statistic-text">{{ option.text }}</span>
</label>
<meter :id="`option-${option.questionId}-${option.id}`"
min="0"
:max="submissions.length"
:value="option.count" />
</li>
</ol>
<!-- Text answers are simply listed for now, could be automatically grouped in the future -->
<ul v-else class="question-summary__text">
<li v-for="answer in textAnswers"
:key="answer.id">
{{ answer }}
</li>
</ul>
</div>
</template>
<script>
import answerTypes from '../../models/AnswerTypes'
export default {
name: 'Summary',
props: {
submissions: {
type: Array,
required: true,
},
question: {
type: Object,
required: true,
},
},
data() {
return {
answerTypes,
}
},
computed: {
// For countable questions like multiple choice and checkboxes
questionOptions() {
// Build list of question options
const questionOptionsStats = this.question.options.map(option => ({
...option,
count: 0,
percentage: 0,
}))
// Also record 'No response'
questionOptionsStats.unshift({
text: t('forms', 'No response'),
count: 0,
percentage: 0,
})
// Go through submissions to check which options have how many responses
this.submissions.forEach(submission => {
const answers = submission.answers.filter(answer => answer.questionId === this.question.id)
if (!answers.length) {
// Record 'No response'
questionOptionsStats[0].count++
}
// Check question options to find which needs to be increased
answers.forEach(answer => {
const optionsStatIndex = questionOptionsStats.findIndex(option => option.text === answer.text)
if (optionsStatIndex < 0) {
questionOptionsStats.push({
'text': answer.text,
'count': 1,
'percentage': 0,
})
} else {
questionOptionsStats[optionsStatIndex].count++
}
})
})
// Sort options by response count
questionOptionsStats.sort((object1, object2) => {
return object2.count - object1.count
})
// Fill percentage values
questionOptionsStats.forEach(questionOptionsStat => {
questionOptionsStat.percentage = Math.round((100 * questionOptionsStat.count) / this.submissions.length)
})
return questionOptionsStats
},
// For text answers like short answer and long text
textAnswers() {
const textAnswers = []
// Also record 'No response'
let noResponseCount = 0
// Go through submissions to check which options have how many responses
this.submissions.forEach(submission => {
const answers = submission.answers.filter(answer => answer.questionId === this.question.id)
if (!answers.length) {
// Record 'No response'
noResponseCount++
}
// Add text answers
answers.forEach(answer => {
textAnswers.push(answer.text)
})
})
// Calculate no response percentage
const noResponsePercentage = Math.round((100 * noResponseCount) / this.submissions.length)
textAnswers.unshift(noResponseCount + ' (' + noResponsePercentage + '%): ' + t('forms', 'No response'))
return textAnswers
},
},
}
</script>
<style lang="scss" scoped>
.question-summary {
padding-left: 16px;
padding-right: 16px;
h3 {
font-weight: bold;
}
&__detail {
color: var(--color-text-lighter);
margin-top: -8px;
}
&__text,
&__statistic {
margin-top: 8px;
}
&__text {
list-style-type: initial;
li {
padding: 4px 0;
&:first-child {
font-weight: bold;
}
}
}
&__statistic {
list-style-type: none;
li {
position: relative;
padding: 8px 0;
label {
cursor: default;
}
&:first-child .question-summary__statistic-text {
font-weight: bold;
}
.question-summary__statistic-percentage {
color: var(--color-text-maxcontrast);
}
meter {
display: block;
width: 100%;
margin-top: 4px;
background: var(--color-background-dark);
height: calc(var(--border-radius) * 2);
border-radius: var(--border-radius);
&::-webkit-meter-bar {
height: calc(var(--border-radius) * 2);
}
// The pseudo-classes of -moz and -webkit have to stay separated even with SCSS, otherwise they dont work
&::-webkit-meter-optimum-value {
background: linear-gradient(40deg, var(--color-primary-element) 0%, var(--color-primary-element-light) 100%);
border-radius: var(--border-radius);
}
&::-moz-meter-bar {
background: linear-gradient(40deg, var(--color-primary-element) 0%, var(--color-primary-element-light) 100%);
border-radius: var(--border-radius);
}
}
}
}
}
</style>

View file

@ -38,15 +38,41 @@
</TopBar>
<header v-if="!noSubmissions">
<h2>{{ t('forms', 'Responses for {title}', { title: formTitle }) }}</h2>
<div class="response_actions">
<button class="response_actions__export" @click="download">
<span class="icon-download" role="img" />
{{ t('forms', 'Export to CSV') }}
</button>
<h2>{{ formTitle }}</h2>
<p>{{ t('forms', '{amount} responses', { amount: form.submissions.length }) }}</p>
<!-- View switcher between Summary and Responses -->
<div class="response-actions">
<div class="response-actions__radio">
<input id="show-summary--true"
v-model="showSummary"
type="radio"
:value="true"
class="hidden">
<label for="show-summary--true"
class="response-actions__radio__item"
:class="{ 'response-actions__radio__item--active': showSummary }">
{{ t('forms', 'Summary') }}
</label>
<input id="show-summary--false"
v-model="showSummary"
type="radio"
:value="false"
class="hidden">
<label for="show-summary--false"
class="response-actions__radio__item"
:class="{ 'response-actions__radio__item--active': !showSummary }">
{{ t('forms', 'Responses') }}
</label>
</div>
<!-- Action menu for CSV export and deletion -->
<Actions class="results-menu"
:aria-label="t('forms', 'Options')"
:force-menu="true">
<ActionButton icon="icon-download" @click="download">
{{ t('forms', 'Export to CSV') }}
</ActionButton>
<ActionButton icon="icon-delete" @click="deleteAllSubmissions">
{{ t('forms', 'Delete all responses') }}
</ActionButton>
@ -69,7 +95,17 @@
</EmptyContent>
</section>
<section v-else>
<!-- Summary view for visualization -->
<section v-if="!noSubmissions && showSummary">
<Summary
v-for="question in form.questions"
:key="question.id"
:question="question"
:submissions="form.submissions" />
</section>
<!-- Responses view for individual responses -->
<section v-if="!noSubmissions && !showSummary">
<Submission
v-for="submission in form.submissions"
:key="submission.id"
@ -93,6 +129,7 @@ import moment from '@nextcloud/moment'
import Vue from 'vue'
import EmptyContent from '../components/EmptyContent'
import Summary from '../components/Results/Summary'
import Submission from '../components/Results/Submission'
import TopBar from '../components/TopBar'
import ViewsMixin from '../mixins/ViewsMixin'
@ -108,6 +145,7 @@ export default {
ActionButton,
AppContent,
EmptyContent,
Summary,
Submission,
TopBar,
},
@ -117,6 +155,7 @@ export default {
data() {
return {
loadingResults: true,
showSummary: true,
}
},
@ -263,23 +302,54 @@ export default {
</script>
<style lang="scss" scoped>
h2 {
font-size: 2em;
font-weight: bold;
margin-top: 32px;
padding-left: 14px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-content header {
h2 {
font-size: 2em;
font-weight: bold;
margin-top: 32px;
padding-left: 14px;
padding-bottom: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.response_actions {
display: flex;
align-items: center;
p {
padding-left: 14px;
}
&__export {
width: max-content;
margin-left: 16px;
.response-actions {
display: flex;
align-items: center;
padding-left: 14px;
&__radio {
margin-right: 8px;
&__item {
border-radius: var(--border-radius-pill);
padding: 8px 16px;
font-weight: bold;
background-color: var(--color-background-dark);
&:first-of-type {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
padding-right: 8px;
}
&:last-of-type {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding-left: 8px;
}
&--active {
background-color: var(--color-primary);
color: var(--color-primary-text)
}
}
}
}
}
</style>